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

Merge with master

This commit is contained in:
Brett Sun 2016-02-05 10:38:59 +01:00
parent b24e66ed11
commit 826ca08073
175 changed files with 4418 additions and 2175 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", "parser": "babel-eslint",
"env": { "env": {
"browser": true, "browser": true,
"es6": true "es6": true,
}, },
"rules": { "rules": {
"new-cap": [2, {newIsCap: true, capIsNew: false}], "new-cap": [2, {newIsCap: true, capIsNew: false}],

11
.gitignore vendored
View File

@ -16,9 +16,14 @@ webapp-dependencies.txt
pids pids
logs logs
results results
build/*
gemini-coverage/*
gemini-report/*
test/gemini/screenshots/*
node_modules/* node_modules/*
build
.DS_Store .DS_Store
.env

View File

@ -1,18 +1,19 @@
Introduction Introduction
============ ============
Onion is the web client for Ascribe. The idea is to have a well documented, Onion is the web client for Ascribe. The idea is to have a well documented, modern, easy to test, easy to hack, JavaScript application.
easy to test, easy to hack, JavaScript application.
The code is JavaScript ECMA 6. The code is JavaScript 2015 / ECMAScript 6.
Getting started Getting started
=============== ===============
Install some nice extension for Chrom(e|ium): Install some nice extension for Chrom(e|ium):
- [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) - [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi)
- [Alt Developer Tools](https://github.com/goatslacker/alt-devtool)
```bash ```bash
git clone git@github.com:ascribe/onion.git git clone git@github.com:ascribe/onion.git
cd onion cd onion
@ -37,43 +38,62 @@ Additionally, to work on the white labeling functionality, you need to edit your
JavaScript Code Conventions JavaScript Code Conventions
=========================== ===========================
For this project, we're using: For this project, we're using:
* 4 Spaces * 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 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 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 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
* We don't use Javascript's `Date` object, as its interface introduced bugs previously and we're including `momentjs` for other dependencies anyways
Branch names Make sure to check out the [style guide](https://github.com/ascribe/javascript).
=====================
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.
``` Linting
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. We use [ESLint](https://github.com/eslint/eslint) with our own [custom ruleset](.eslintrc).
This allows JIRA to still track branches and pull-requests while allowing us to keep our peace of mind.
Example
-------------
**JIRA ticket name:** `AD-1242 - Frontend caching for simple endpoints to measure perceived page load <more useless information>`
**Github branch name:** `AD-1242-caching-solution-for-stores`
SCSS Code Conventions 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. 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: 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) * [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:
```
// 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 issue or ticket's title.
Example
-------
**JIRA ticket name:** `AD-1242 - Frontend caching for simple endpoints to measure perceived page load <more useless information>`
**Github branch name:** `AD-1242-caching-solution-for-stores`
Testing Testing
=============== =======
Unit Testing
------------
We're using Facebook's jest to do testing as it integrates nicely with react.js as well. 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 `<file_name>_tests.js` file needs to be created. Tests are always created per directory by creating a `__tests__` folder. To test a specific file, a `<file_name>_tests.js` file needs to be created.
@ -83,7 +103,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. 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. 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`. If you want to test exclusively (without having the obnoxious ES6Linter warnings), you can just run `gulp jest:watch`.
@ -134,9 +171,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 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. Q: I want to know all dependencies that get bundled into the live build.
A: ```browserify -e js/app.js --list > webapp-dependencies.txt``` A: ```browserify -e js/app.js --list > webapp-dependencies.txt```
Reading list Reading list
============ ============
@ -149,7 +193,6 @@ Start here
- [alt.js](http://alt.js.org/) - [alt.js](http://alt.js.org/)
- [alt.js readme](https://github.com/goatslacker/alt) - [alt.js readme](https://github.com/goatslacker/alt)
Moar stuff Moar stuff
---------- ----------

View File

@ -97,7 +97,8 @@ gulp.task('browser-sync', function() {
proxy: 'http://localhost:4000', proxy: 'http://localhost:4000',
port: 3000, port: 3000,
open: false, // does not open the browser-window anymore (handled manually) open: false, // does not open the browser-window anymore (handled manually)
ghostMode: false ghostMode: false,
notify: false // stop showing the browsersync pop up
}); });
}); });

View File

@ -28,12 +28,10 @@ class ContractListActions {
} }
changeContract(contract){ changeContract(contract) {
return Q.Promise((resolve, reject) => { return Q.Promise((resolve, reject) => {
OwnershipFetcher.changeContract(contract) OwnershipFetcher.changeContract(contract)
.then((res) => { .then(resolve)
resolve(res);
})
.catch((err)=> { .catch((err)=> {
console.logGlobal(err); console.logGlobal(err);
reject(err); reject(err);
@ -41,13 +39,11 @@ class ContractListActions {
}); });
} }
removeContract(contractId){ removeContract(contractId) {
return Q.Promise( (resolve, reject) => { return Q.Promise((resolve, reject) => {
OwnershipFetcher.deleteContract(contractId) OwnershipFetcher.deleteContract(contractId)
.then((res) => { .then(resolve)
resolve(res); .catch((err) => {
})
.catch( (err) => {
console.logGlobal(err); console.logGlobal(err);
reject(err); reject(err);
}); });

View File

@ -7,11 +7,11 @@ class EditionActions {
constructor() { constructor() {
this.generateActions( this.generateActions(
'fetchEdition', 'fetchEdition',
'successFetchEdition',
'successFetchCoa', 'successFetchCoa',
'flushEdition', 'successFetchEdition',
'errorCoa', 'errorCoa',
'errorEdition' 'errorEdition',
'flushEdition'
); );
} }
} }

View File

@ -17,23 +17,31 @@ class EditionListActions {
); );
} }
fetchEditionList(pieceId, page, pageSize, orderBy, orderAsc, filterBy) { fetchEditionList({ pieceId, page, pageSize, orderBy, orderAsc, filterBy, maxEdition }) {
if((!orderBy && typeof orderAsc === 'undefined') || !orderAsc) { if ((!orderBy && typeof orderAsc === 'undefined') || !orderAsc) {
orderBy = 'edition_number'; orderBy = 'edition_number';
orderAsc = true; orderAsc = true;
} }
// Taken from: http://stackoverflow.com/a/519157/1263876 // Taken from: http://stackoverflow.com/a/519157/1263876
if((typeof page === 'undefined' || !page) && (typeof pageSize === 'undefined' || !pageSize)) { if ((typeof page === 'undefined' || !page) && (typeof pageSize === 'undefined' || !pageSize)) {
page = 1; page = 1;
pageSize = 10; pageSize = 10;
} }
let itemsToFetch = pageSize;
// If we only want to fetch up to a specified edition, fetch all pages up to it
// as one page and adjust afterwards
if (typeof maxEdition === 'number') {
itemsToFetch = Math.ceil(maxEdition / pageSize) * pageSize;
page = 1;
}
return Q.Promise((resolve, reject) => { return Q.Promise((resolve, reject) => {
EditionListFetcher EditionListFetcher
.fetch(pieceId, page, pageSize, orderBy, orderAsc, filterBy) .fetch({ pieceId, page, orderBy, orderAsc, filterBy, pageSize: itemsToFetch })
.then((res) => { .then((res) => {
if(res && !res.editions) { if (res && !res.editions) {
throw new Error('Piece has no editions to fetch.'); throw new Error('Piece has no editions to fetch.');
} }
@ -44,8 +52,9 @@ class EditionListActions {
orderBy, orderBy,
orderAsc, orderAsc,
filterBy, filterBy,
'editionListOfPiece': res.editions, maxEdition,
'count': res.count count: res.count,
editionListOfPiece: res.editions
}); });
resolve(res); resolve(res);
}) })
@ -54,7 +63,6 @@ class EditionListActions {
reject(err); reject(err);
}); });
}); });
} }
} }

View File

@ -0,0 +1,14 @@
'use strict';
import { altThirdParty } from '../alt';
class FacebookActions {
constructor() {
this.generateActions(
'sdkReady'
);
}
}
export default altThirdParty.createActions(FacebookActions);

View File

@ -1,28 +1,19 @@
'use strict'; 'use strict';
import { alt } from '../alt'; import { alt } from '../alt';
import PieceFetcher from '../fetchers/piece_fetcher';
class PieceActions { class PieceActions {
constructor() { constructor() {
this.generateActions( this.generateActions(
'fetchPiece',
'successFetchPiece',
'errorPiece',
'flushPiece',
'updatePiece', 'updatePiece',
'updateProperty', 'updateProperty'
'pieceFailed'
); );
} }
fetchOne(pieceId) {
PieceFetcher.fetchOne(pieceId)
.then((res) => {
this.actions.updatePiece(res.piece);
})
.catch((err) => {
console.logGlobal(err);
this.actions.pieceFailed(err.json);
});
}
} }
export default alt.createActions(PieceActions); export default alt.createActions(PieceActions);

View File

@ -15,7 +15,7 @@ class PieceListActions {
); );
} }
fetchPieceList(page, pageSize, search, orderBy, orderAsc, filterBy) { fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy }) {
// To prevent flickering on a pagination request, // To prevent flickering on a pagination request,
// we overwrite the piecelist with an empty list before // we overwrite the piecelist with an empty list before
// pieceListCount === -1 defines the loading state // pieceListCount === -1 defines the loading state
@ -34,7 +34,7 @@ class PieceListActions {
// afterwards, we can load the list // afterwards, we can load the list
return Q.Promise((resolve, reject) => { return Q.Promise((resolve, reject) => {
PieceListFetcher PieceListFetcher
.fetch(page, pageSize, search, orderBy, orderAsc, filterBy) .fetch({ page, pageSize, search, orderBy, orderAsc, filterBy })
.then((res) => { .then((res) => {
this.actions.updatePieceList({ this.actions.updatePieceList({
page, page,

View File

@ -1,34 +0,0 @@
'use strict';
import { alt } from '../alt';
import Q from 'q';
import PrizeListFetcher from '../fetchers/prize_list_fetcher';
class PrizeListActions {
constructor() {
this.generateActions(
'updatePrizeList'
);
}
fetchPrizeList() {
return Q.Promise((resolve, reject) => {
PrizeListFetcher
.fetch()
.then((res) => {
this.actions.updatePrizeList({
prizeList: res.prizes,
prizeListCount: res.count
});
resolve(res);
})
.catch((err) => {
console.logGlobal(err);
reject(err);
});
});
}
}
export default alt.createActions(PrizeListActions);

View File

@ -1,14 +1,13 @@
'use strict'; 'use strict';
import 'babel/polyfill'; import 'babel/polyfill';
import 'classlist-polyfill';
import React from 'react'; import React from 'react';
import { Router, Redirect } from 'react-router'; import { Router, Redirect } from 'react-router';
import history from './history'; import history from './history';
/* eslint-disable */
import fetch from 'isomorphic-fetch'; import fetch from 'isomorphic-fetch';
/* eslint-enable */
import ApiUrls from './constants/api_urls'; import ApiUrls from './constants/api_urls';
@ -17,44 +16,27 @@ import getRoutes from './routes';
import requests from './utils/requests'; import requests from './utils/requests';
import { updateApiUrls } from './constants/api_urls'; import { updateApiUrls } from './constants/api_urls';
import { getSubdomainSettings } from './utils/constants_utils'; import { getDefaultSubdomainSettings, getSubdomainSettings } from './utils/constants_utils';
import { initLogging } from './utils/error_utils'; import { initLogging } from './utils/error_utils';
import { getSubdomain } from './utils/general_utils'; import { getSubdomain } from './utils/general_utils';
import EventActions from './actions/event_actions'; import EventActions from './actions/event_actions';
/* eslint-disable */
// You can comment out the modules you don't need // You can comment out the modules you don't need
// import DebugHandler from './third_party/debug'; // import DebugHandler from './third_party/debug_handler';
import GoogleAnalyticsHandler from './third_party/ga'; import FacebookHandler from './third_party/facebook_handler';
import RavenHandler from './third_party/raven'; import GoogleAnalyticsHandler from './third_party/ga_handler';
import IntercomHandler from './third_party/intercom'; import IntercomHandler from './third_party/intercom_handler';
import NotificationsHandler from './third_party/notifications'; import NotificationsHandler from './third_party/notifications_handler';
import FacebookHandler from './third_party/facebook'; import RavenHandler from './third_party/raven_handler';
/* eslint-enable */
initLogging();
let headers = { const AppGateway = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
requests.defaults({
urlMap: ApiUrls,
http: {
headers: headers,
credentials: 'include'
}
});
class AppGateway {
start() { start() {
let settings;
let subdomain = getSubdomain();
try { try {
settings = getSubdomainSettings(subdomain); const subdomain = getSubdomain();
const settings = getSubdomainSettings(subdomain);
AppConstants.whitelabel = settings; AppConstants.whitelabel = settings;
updateApiUrls(settings.type, subdomain); updateApiUrls(settings.type, subdomain);
this.load(settings); this.load(settings);
@ -62,28 +44,25 @@ class AppGateway {
// if there are no matching subdomains, we're routing // if there are no matching subdomains, we're routing
// to the default frontend // to the default frontend
console.logGlobal(err); console.logGlobal(err);
this.load(); this.load(getDefaultSubdomainSettings());
} }
} },
load(settings) { load(settings) {
let type = 'default'; const { subdomain, type } = settings;
let subdomain = 'www';
let redirectRoute = (<Redirect from="/" to="/collection" />); let redirectRoute = (<Redirect from="/" to="/collection" />);
if (settings) { if (subdomain) {
type = settings.type; // Some whitelabels have landing pages so we should not automatically redirect from / to /collection.
subdomain = settings.subdomain; // Only www and cc do not have a landing page.
} if (subdomain !== 'cc') {
redirectRoute = null;
}
// www and cc do not have a landing page // Adds a client specific class to the body for whitelabel styling
if(subdomain && subdomain !== 'cc') { window.document.body.classList.add('client--' + subdomain);
redirectRoute = null;
} }
// Adds a client specific class to the body for whitelabel styling
window.document.body.classList.add('client--' + subdomain);
// Send the applicationWillBoot event to the third-party stores // Send the applicationWillBoot event to the third-party stores
EventActions.applicationWillBoot(settings); EventActions.applicationWillBoot(settings);
@ -101,8 +80,21 @@ class AppGateway {
// Send the applicationDidBoot event to the third-party stores // Send the applicationDidBoot event to the third-party stores
EventActions.applicationDidBoot(settings); EventActions.applicationDidBoot(settings);
} }
} };
let ag = new AppGateway(); // Initialize pre-start components
ag.start(); initLogging();
requests.defaults({
urlMap: ApiUrls,
http: {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'include'
}
});
// And bootstrap app
AppGateway.start();

90
js/components/app_base.js Normal file
View File

@ -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 (
<div>
<App
{...this.props}
activeRoute={activeRoute}
currentUser={currentUser}
whitelabel={whitelabel} />
<GlobalNotification />
<div id="modal" className="container" />
</div>
);
}
});
};

View File

@ -24,7 +24,7 @@ const AppRouteWrapper = React.createClass({
} }
return ( return (
<div className="ascribe-body"> <div className="container ascribe-body">
{childrenWithProps} {childrenWithProps}
</div> </div>
); );

View File

@ -8,9 +8,10 @@ import { getLangText } from '../../utils/lang_utils';
let AccordionList = React.createClass({ let AccordionList = React.createClass({
propTypes: { propTypes: {
className: React.PropTypes.string,
children: React.PropTypes.arrayOf(React.PropTypes.element).isRequired, 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, count: React.PropTypes.number,
itemList: React.PropTypes.arrayOf(React.PropTypes.object), itemList: React.PropTypes.arrayOf(React.PropTypes.object),
search: React.PropTypes.string, search: React.PropTypes.string,
@ -24,7 +25,7 @@ let AccordionList = React.createClass({
render() { render() {
const { search } = this.props; const { search } = this.props;
if(this.props.itemList && this.props.itemList.length > 0) { if (this.props.itemList && this.props.itemList.length > 0) {
return ( return (
<div className={this.props.className}> <div className={this.props.className}>
{this.props.children} {this.props.children}

View File

@ -19,9 +19,10 @@ import { getLangText } from '../../utils/lang_utils';
let AccordionListItemEditionWidget = React.createClass({ let AccordionListItemEditionWidget = React.createClass({
propTypes: { propTypes: {
className: React.PropTypes.string,
piece: React.PropTypes.object.isRequired, piece: React.PropTypes.object.isRequired,
toggleCreateEditionsDialog: React.PropTypes.func.isRequired, toggleCreateEditionsDialog: React.PropTypes.func.isRequired,
className: React.PropTypes.string,
onPollingSuccess: React.PropTypes.func onPollingSuccess: React.PropTypes.func
}, },
@ -50,14 +51,15 @@ let AccordionListItemEditionWidget = React.createClass({
* Calls the store to either show or hide the editionListTable * Calls the store to either show or hide the editionListTable
*/ */
toggleTable() { toggleTable() {
let pieceId = this.props.piece.id; const { piece: { id: pieceId } } = this.props;
let isEditionListOpen = this.state.isEditionListOpenForPieceId[pieceId] ? this.state.isEditionListOpenForPieceId[pieceId].show : false; const { filterBy, isEditionListOpenForPieceId } = this.state;
const isEditionListOpen = isEditionListOpenForPieceId[pieceId] ? isEditionListOpenForPieceId[pieceId].show : false;
if(isEditionListOpen) { if (isEditionListOpen) {
EditionListActions.toggleEditionList(pieceId); EditionListActions.toggleEditionList(pieceId);
} else { } else {
EditionListActions.toggleEditionList(pieceId); EditionListActions.toggleEditionList(pieceId);
EditionListActions.fetchEditionList(pieceId, null, null, null, null, this.state.filterBy); EditionListActions.fetchEditionList({ pieceId, filterBy });
} }
}, },

View File

@ -66,20 +66,28 @@ let AccordionListItemTableEditions = React.createClass({
}, },
filterSelectedEditions() { filterSelectedEditions() {
let selectedEditions = this.state.editionList[this.props.parentId] return this.state
.filter((edition) => edition.selected); .editionList[this.props.parentId]
return selectedEditions; .filter((edition) => edition.selected);
}, },
loadFurtherEditions() { loadFurtherEditions() {
const { parentId: pieceId } = this.props;
const { page, pageSize, orderBy, orderAsc, filterBy } = this.state.editionList[pieceId];
// trigger loading animation // trigger loading animation
this.setState({ this.setState({
showMoreLoading: true showMoreLoading: true
}); });
let editionList = this.state.editionList[this.props.parentId]; EditionListActions.fetchEditionList({
EditionListActions.fetchEditionList(this.props.parentId, editionList.page + 1, editionList.pageSize, pieceId,
editionList.orderBy, editionList.orderAsc, editionList.filterBy); pageSize,
orderBy,
orderAsc,
filterBy,
page: page + 1
});
}, },
render() { render() {
const { className, parentId } = this.props; const { className, parentId } = this.props;

View File

@ -1,4 +1,4 @@
'use strict' 'use strict';
import React from 'react'; import React from 'react';

View File

@ -31,12 +31,12 @@ let AccordionListItemWallet = React.createClass({
content: React.PropTypes.object.isRequired, content: React.PropTypes.object.isRequired,
whitelabel: React.PropTypes.object.isRequired, whitelabel: React.PropTypes.object.isRequired,
className: React.PropTypes.string,
thumbnailPlaceholder: React.PropTypes.func,
children: React.PropTypes.oneOfType([ children: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.arrayOf(React.PropTypes.element),
React.PropTypes.element React.PropTypes.element
]) ]),
className: React.PropTypes.string,
thumbnailPlaceholder: React.PropTypes.func
}, },
getInitialState() { getInitialState() {
@ -67,10 +67,12 @@ let AccordionListItemWallet = React.createClass({
delay={500} delay={500}
placement="left" placement="left"
overlay={<Tooltip>{getLangText('You have actions pending')}</Tooltip>}> overlay={<Tooltip>{getLangText('You have actions pending')}</Tooltip>}>
<Glyphicon glyph='bell' color="green"/> <Glyphicon glyph='bell' color="green" />
</OverlayTrigger>); </OverlayTrigger>
);
} else {
return null;
} }
return null;
}, },
toggleCreateEditionsDialog() { toggleCreateEditionsDialog() {
@ -86,8 +88,9 @@ let AccordionListItemWallet = React.createClass({
}, },
onPollingSuccess(pieceId) { onPollingSuccess(pieceId) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
EditionListActions.toggleEditionList(pieceId); EditionListActions.toggleEditionList(pieceId);
const notification = new GlobalNotificationModel(getLangText('Editions successfully created'), 'success', 10000); const notification = new GlobalNotificationModel(getLangText('Editions successfully created'), 'success', 10000);

View File

@ -2,60 +2,28 @@
import React from 'react'; import React from 'react';
import UserActions from '../actions/user_actions'; import AppBase from './app_base';
import UserStore from '../stores/user_store';
import WhitelabelActions from '../actions/whitelabel_actions';
import WhitelabelStore from '../stores/whitelabel_store';
import AppRouteWrapper from './app_route_wrapper'; import AppRouteWrapper from './app_route_wrapper';
import Header from './header';
import Footer from './footer'; import Footer from './footer';
import GlobalNotification from './global_notification'; import Header from './header';
import { mergeOptions } from '../utils/general_utils';
let AscribeApp = React.createClass({ let AscribeApp = React.createClass({
propTypes: { propTypes: {
activeRoute: React.PropTypes.object.isRequired,
children: React.PropTypes.element.isRequired,
routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
children: React.PropTypes.oneOfType([ // Provided from AppBase
React.PropTypes.arrayOf(React.PropTypes.element), currentUser: React.PropTypes.object,
React.PropTypes.element whitelabel: React.PropTypes.object
])
},
getInitialState() {
return mergeOptions(
UserStore.getState(),
WhitelabelStore.getState()
);
},
componentDidMount() {
UserStore.listen(this.onChange);
WhitelabelStore.listen(this.onChange);
UserActions.fetchCurrentUser();
WhitelabelActions.fetchWhitelabel();
},
componentWillUnmount() {
UserStore.unlisten(this.onChange);
WhitelabelActions.unlisten(this.onChange);
},
onChange(state) {
this.setState(state);
}, },
render() { render() {
const { children, routes } = this.props; const { activeRoute, children, currentUser, routes, whitelabel } = this.props;
const { currentUser, whitelabel } = this.state;
return ( return (
<div className="container ascribe-default-app"> <div className="ascribe-default-app">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
routes={routes} routes={routes}
@ -66,12 +34,10 @@ let AscribeApp = React.createClass({
{/* Routes are injected here */} {/* Routes are injected here */}
{children} {children}
</AppRouteWrapper> </AppRouteWrapper>
<Footer /> <Footer activeRoute={activeRoute} />
<GlobalNotification />
<div id="modal" className="container"></div>
</div> </div>
); );
} }
}); });
export default AscribeApp; export default AppBase(AscribeApp);

View File

@ -14,7 +14,7 @@ import AppConstants from '../../../constants/application_constants';
import { AclInformationText } from '../../../constants/acl_information_text'; 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) { if (AppConstants.aclList.indexOf(action) < 0) {
console.warn('Your specified aclName did not match a an acl class.'); console.warn('Your specified aclName did not match a an acl class.');
} }

View File

@ -28,6 +28,12 @@ let CreateEditionsButton = React.createClass({
EditionListStore.listen(this.onChange); EditionListStore.listen(this.onChange);
}, },
componentDidUpdate() {
if(this.props.piece.num_editions === 0 && typeof this.state.pollingIntervalIndex === 'undefined') {
this.startPolling();
}
},
componentWillUnmount() { componentWillUnmount() {
EditionListStore.unlisten(this.onChange); EditionListStore.unlisten(this.onChange);
clearInterval(this.state.pollingIntervalIndex); clearInterval(this.state.pollingIntervalIndex);
@ -37,28 +43,24 @@ let CreateEditionsButton = React.createClass({
this.setState(state); this.setState(state);
}, },
componentDidUpdate() {
if(this.props.piece.num_editions === 0 && typeof this.state.pollingIntervalIndex === 'undefined') {
this.startPolling();
}
},
startPolling() { startPolling() {
// start polling until editions are defined // start polling until editions are defined
let pollingIntervalIndex = setInterval(() => { let pollingIntervalIndex = setInterval(() => {
// requests, will try to merge the filterBy parameter with other parameters (mergeOptions). // requests, will try to merge the filterBy parameter with other parameters (mergeOptions).
// Therefore it can't but null but instead has to be an empty object // Therefore it can't but null but instead has to be an empty object
EditionListActions.fetchEditionList(this.props.piece.id, null, null, null, null, {}) EditionListActions
.then((res) => { .fetchEditionList({
pieceId: this.props.piece.id,
clearInterval(this.state.pollingIntervalIndex); filterBy: {}
this.props.onPollingSuccess(this.props.piece.id, res.editions[0].num_editions); })
.then((res) => {
}) clearInterval(this.state.pollingIntervalIndex);
.catch((err) => { this.props.onPollingSuccess(this.props.piece.id, res.editions[0].num_editions);
/* Ignore and keep going */ })
}); .catch((err) => {
/* Ignore and keep going */
});
}, 5000); }, 5000);
this.setState({ this.setState({

View File

@ -74,7 +74,7 @@ let Edition = React.createClass({
<div className="ascribe-detail-header"> <div className="ascribe-detail-header">
<hr className="hidden-print" style={{marginTop: 0}} /> <hr className="hidden-print" style={{marginTop: 0}} />
<h1 className="ascribe-detail-title">{edition.title}</h1> <h1 className="ascribe-detail-title">{edition.title}</h1>
<DetailProperty label="BY" value={edition.artist_name} /> <DetailProperty label="CREATED BY" value={edition.artist_name} />
<DetailProperty label="DATE" value={Moment(edition.date_created, 'YYYY-MM-DD').year()} /> <DetailProperty label="DATE" value={Moment(edition.date_created, 'YYYY-MM-DD').year()} />
<hr /> <hr />
</div> </div>

View File

@ -81,9 +81,10 @@ let EditionActionPanel = React.createClass({
}, },
refreshCollection() { refreshCollection() {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
EditionListActions.refreshEditionList({pieceId: this.props.edition.parent}); PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
EditionListActions.refreshEditionList({ pieceId: this.props.edition.parent });
}, },
handleSuccess(response) { handleSuccess(response) {

View File

@ -37,31 +37,28 @@ let EditionContainer = React.createClass({
mixins: [History, ReactError], mixins: [History, ReactError],
getInitialState() { getInitialState() {
return EditionStore.getState(); return EditionStore.getInitialState();
}, },
componentDidMount() { componentDidMount() {
EditionStore.listen(this.onChange); EditionStore.listen(this.onChange);
// Every time we're entering the edition detail page, this.loadEdition();
// just reset the edition that is saved in the edition store
// as it will otherwise display wrong/old data once the user loads
// the edition detail a second time
EditionActions.flushEdition();
EditionActions.fetchEdition(this.props.params.editionId);
}, },
// This is done to update the container when the user clicks on the prev or next // This is done to update the container when the user clicks on the prev or next
// button to update the URL parameter (and therefore to switch pieces) // button to update the URL parameter (and therefore to switch pieces)
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.props.params.editionId !== nextProps.params.editionId) { if (this.props.params.editionId !== nextProps.params.editionId) {
EditionActions.fetchEdition(this.props.params.editionId); EditionActions.flushEdition();
this.loadEdition(nextProps.params.editionId);
} }
}, },
componentDidUpdate() { componentDidUpdate() {
const { editionMeta } = this.state; const { err: editionErr } = this.state.editionMeta;
if (editionMeta.err && editionMeta.err.json && editionMeta.err.json.status === 404) {
if (editionErr && editionErr.json && editionErr.json.status === 404) {
this.throws(new ResourceNotFoundError(getLangText("Oops, the edition you're looking for doesn't exist."))); this.throws(new ResourceNotFoundError(getLangText("Oops, the edition you're looking for doesn't exist.")));
} }
}, },
@ -75,12 +72,16 @@ let EditionContainer = React.createClass({
this.setState(state); this.setState(state);
}, },
loadEdition(editionId = this.props.params.editionId) {
EditionActions.fetchEdition(editionId);
},
render() { render() {
const { actionPanelButtonListType, currentUser, furtherDetailsType, whitelabel } = this.props; const { actionPanelButtonListType, currentUser, furtherDetailsType, whitelabel } = this.props;
const { edition, coaMeta } = this.state; const { edition, coaMeta } = this.state;
if (Object.keys(edition).length && edition.id) { if (edition.id) {
setDocumentTitle([edition.artist_name, edition.title].join(', ')); setDocumentTitle(`${edition.artist_name}, ${edition.title}`);
return ( return (
<Edition <Edition
@ -89,7 +90,7 @@ let EditionContainer = React.createClass({
currentUser={currentUser} currentUser={currentUser}
edition={edition} edition={edition}
furtherDetailsType={furtherDetailsType} furtherDetailsType={furtherDetailsType}
loadEdition={() => EditionActions.fetchEdition(this.props.params.editionId)} loadEdition={this.loadEdition}
whitelabel={whitelabel} /> whitelabel={whitelabel} />
); );
} else { } else {

View File

@ -5,25 +5,27 @@ import React from 'react';
import Row from 'react-bootstrap/lib/Row'; import Row from 'react-bootstrap/lib/Row';
import Col from 'react-bootstrap/lib/Col'; import Col from 'react-bootstrap/lib/Col';
import Form from './../ascribe_forms/form';
import PieceExtraDataForm from './../ascribe_forms/form_piece_extradata';
import GlobalNotificationModel from '../../models/global_notification_model'; import GlobalNotificationModel from '../../models/global_notification_model';
import GlobalNotificationActions from '../../actions/global_notification_actions'; import GlobalNotificationActions from '../../actions/global_notification_actions';
import FurtherDetailsFileuploader from './further_details_fileuploader'; import FurtherDetailsFileuploader from './further_details_fileuploader';
import Form from './../ascribe_forms/form';
import PieceExtraDataForm from './../ascribe_forms/form_piece_extradata';
import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils'; import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils';
import { getLangText } from '../../utils/lang_utils';
let FurtherDetails = React.createClass({ let FurtherDetails = React.createClass({
propTypes: { propTypes: {
pieceId: React.PropTypes.number.isRequired,
editable: React.PropTypes.bool, editable: React.PropTypes.bool,
pieceId: React.PropTypes.number,
extraData: React.PropTypes.object, extraData: React.PropTypes.object,
handleSuccess: React.PropTypes.func,
otherData: React.PropTypes.arrayOf(React.PropTypes.object), otherData: React.PropTypes.arrayOf(React.PropTypes.object),
handleSuccess: React.PropTypes.func
}, },
getInitialState() { getInitialState() {
@ -32,13 +34,18 @@ let FurtherDetails = React.createClass({
}; };
}, },
showNotification(){ showNotification() {
this.props.handleSuccess(); const { handleSuccess } = this.props;
let notification = new GlobalNotificationModel('Details updated', 'success');
if (typeof handleSucess === 'function') {
handleSuccess();
}
const notification = new GlobalNotificationModel(getLangText('Details updated'), 'success');
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
submitFile(file){ submitFile(file) {
this.setState({ this.setState({
otherDataKey: file.key otherDataKey: file.key
}); });
@ -51,40 +58,42 @@ let FurtherDetails = React.createClass({
}, },
render() { render() {
const { editable, extraData, otherData, pieceId } = this.props;
return ( return (
<Row> <Row>
<Col md={12} className="ascribe-edition-personal-note"> <Col md={12} className="ascribe-edition-personal-note">
<PieceExtraDataForm <PieceExtraDataForm
name='artist_contact_info' name='artist_contact_info'
title='Artist Contact Info' title='Artist Contact Info'
convertLinks
editable={editable}
extraData={extraData}
handleSuccess={this.showNotification} handleSuccess={this.showNotification}
editable={this.props.editable} pieceId={pieceId} />
pieceId={this.props.pieceId}
extraData={this.props.extraData}
/>
<PieceExtraDataForm <PieceExtraDataForm
name='display_instructions' name='display_instructions'
title='Display Instructions' title='Display Instructions'
editable={editable}
extraData={extraData}
handleSuccess={this.showNotification} handleSuccess={this.showNotification}
editable={this.props.editable} pieceId={pieceId} />
pieceId={this.props.pieceId}
extraData={this.props.extraData} />
<PieceExtraDataForm <PieceExtraDataForm
name='technology_details' name='technology_details'
title='Technology Details' title='Technology Details'
editable={editable}
extraData={extraData}
handleSuccess={this.showNotification} handleSuccess={this.showNotification}
editable={this.props.editable} pieceId={pieceId} />
pieceId={this.props.pieceId}
extraData={this.props.extraData} />
<Form> <Form>
<FurtherDetailsFileuploader <FurtherDetailsFileuploader
submitFile={this.submitFile} submitFile={this.submitFile}
setIsUploadReady={this.setIsUploadReady} setIsUploadReady={this.setIsUploadReady}
isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile} isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile}
editable={this.props.editable} editable={editable}
overrideForm={true} overrideForm={true}
pieceId={this.props.pieceId} pieceId={pieceId}
otherData={this.props.otherData} otherData={otherData}
multiple={true} /> multiple={true} />
</Form> </Form>
</Col> </Col>

View File

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

View File

@ -14,7 +14,9 @@ import CollapsibleButton from './../ascribe_collapsible/collapsible_button';
import AclProxy from '../acl_proxy'; import AclProxy from '../acl_proxy';
import { getLangText } from '../../utils/lang_utils.js'; import { getLangText } from '../../utils/lang_utils';
import { extractFileExtensionFromString } from '../../utils/file_utils';
const EMBED_IFRAME_HEIGHT = { const EMBED_IFRAME_HEIGHT = {
video: 315, video: 315,
@ -68,6 +70,16 @@ let MediaContainer = React.createClass({
// content was registered by the current user. // content was registered by the current user.
const didUserRegisterContent = currentUser && (currentUser.username === content.user_registered); const didUserRegisterContent = currentUser && (currentUser.username === content.user_registered);
// 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'] ? let thumbnail = content.thumbnail.thumbnail_sizes && content.thumbnail.thumbnail_sizes['600x600'] ?
content.thumbnail.thumbnail_sizes['600x600'] : content.thumbnail.url_safe; content.thumbnail.thumbnail_sizes['600x600'] : content.thumbnail.url_safe;
let mimetype = content.digital_work.mime; let mimetype = content.digital_work.mime;
@ -124,7 +136,11 @@ let MediaContainer = React.createClass({
className="ascribe-margin-1px" className="ascribe-margin-1px"
href={content.digital_work.url} href={content.digital_work.url}
target="_blank"> target="_blank">
{getLangText('Download')} .{mimetype} <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> </Button>
</AclProxy> </AclProxy>
{embed} {embed}

View File

@ -27,7 +27,7 @@ let Piece = React.createClass({
}, },
updatePiece() { updatePiece() {
return PieceActions.fetchOne(this.props.piece.id); return PieceActions.fetchPiece(this.props.piece.id);
}, },
render() { render() {

View File

@ -71,7 +71,7 @@ let PieceContainer = React.createClass({
getInitialState() { getInitialState() {
return mergeOptions( return mergeOptions(
PieceListStore.getState(), PieceListStore.getState(),
PieceStore.getState(), PieceStore.getInitialState(),
{ {
showCreateEditionsDialog: false showCreateEditionsDialog: false
} }
@ -82,18 +82,23 @@ let PieceContainer = React.createClass({
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
PieceStore.listen(this.onChange); PieceStore.listen(this.onChange);
// Every time we enter the piece detail page, just reset the piece
// store as it will otherwise display wrong/old data once the user loads
// the piece detail a second time
PieceActions.updatePiece({});
this.loadPiece(); this.loadPiece();
}, },
componentDidUpdate() { // This is done to update the container when the user clicks on the prev or next
const { pieceError } = this.state; // button to update the URL parameter (and therefore to switch pieces) or
// when the user clicks on a notification while being in another piece view
componentWillReceiveProps(nextProps) {
if (this.props.params.pieceId !== nextProps.params.pieceId) {
PieceActions.flushPiece();
this.loadPiece(nextProps.params.pieceId);
}
},
if (pieceError && pieceError.status === 404) { componentDidUpdate() {
const { err: pieceErr } = this.state.pieceMeta;
if (pieceErr && pieceErr.json && pieceErr.json.status === 404) {
this.throws(new ResourceNotFoundError(getLangText("Oops, the piece you're looking for doesn't exist."))); this.throws(new ResourceNotFoundError(getLangText("Oops, the piece you're looking for doesn't exist.")));
} }
}, },
@ -115,8 +120,7 @@ let PieceContainer = React.createClass({
ALSO, WE ENABLED THE LOAN BUTTON FOR IKONOTV TO LET THEM LOAN ON A PIECE LEVEL ALSO, WE ENABLED THE LOAN BUTTON FOR IKONOTV TO LET THEM LOAN ON A PIECE LEVEL
*/ */
if(state && state.piece && state.piece.acl && typeof state.piece.acl.acl_loan !== 'undefined') { if (state && state.piece && state.piece.acl && typeof state.piece.acl.acl_loan !== 'undefined') {
let pieceState = mergeOptions({}, state.piece); let pieceState = mergeOptions({}, state.piece);
pieceState.acl.acl_loan = false; pieceState.acl.acl_loan = false;
this.setState({ this.setState({
@ -128,11 +132,10 @@ let PieceContainer = React.createClass({
} }
}, },
loadPiece() { loadPiece(pieceId = this.props.params.pieceId) {
PieceActions.fetchOne(this.props.params.pieceId); PieceActions.fetchPiece(pieceId);
}, },
toggleCreateEditionsDialog() { toggleCreateEditionsDialog() {
this.setState({ this.setState({
showCreateEditionsDialog: !this.state.showCreateEditionsDialog showCreateEditionsDialog: !this.state.showCreateEditionsDialog
@ -140,43 +143,47 @@ let PieceContainer = React.createClass({
}, },
handleEditionCreationSuccess() { handleEditionCreationSuccess() {
PieceActions.updateProperty({key: 'num_editions', value: 0}); const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search,
this.state.orderBy, this.state.orderAsc, this.state.filterBy); PieceActions.updateProperty({ key: 'num_editions', value: 0 });
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.toggleCreateEditionsDialog(); this.toggleCreateEditionsDialog();
}, },
handleDeleteSuccess(response) { handleDeleteSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
// since we're deleting a piece, we just need to close // since we're deleting a piece, we just need to close
// all editions dialogs and not reload them // all editions dialogs and not reload them
EditionListActions.closeAllEditionLists(); EditionListActions.closeAllEditionLists();
EditionListActions.clearAllEditionSelections(); EditionListActions.clearAllEditionSelections();
let notification = new GlobalNotificationModel(response.notification, 'success'); const notification = new GlobalNotificationModel(response.notification, 'success');
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
this.history.push('/collection'); this.history.push('/collection');
}, },
getCreateEditionsDialog() { getCreateEditionsDialog() {
if(this.state.piece.num_editions < 1 && this.state.showCreateEditionsDialog) { if (this.state.piece.num_editions < 1 && this.state.showCreateEditionsDialog) {
return ( return (
<div style={{marginTop: '1em'}}> <div style={{marginTop: '1em'}}>
<CreateEditionsForm <CreateEditionsForm
pieceId={this.state.piece.id} pieceId={this.state.piece.id}
handleSuccess={this.handleEditionCreationSuccess} /> handleSuccess={this.handleEditionCreationSuccess} />
<hr/> <hr />
</div> </div>
); );
} else { } else {
return (<hr/>); return (<hr />);
} }
}, },
handlePollingSuccess(pieceId, numEditions) { handlePollingSuccess(pieceId, numEditions) {
const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
// we need to refresh the num_editions property of the actual piece we're looking at // we need to refresh the num_editions property of the actual piece we're looking at
PieceActions.updateProperty({ PieceActions.updateProperty({
@ -188,28 +195,28 @@ let PieceContainer = React.createClass({
// btw.: It's not sufficient to just set num_editions to numEditions, since a single accordion // btw.: It's not sufficient to just set num_editions to numEditions, since a single accordion
// list item also uses the firstEdition property which we can only get from the server in that case. // list item also uses the firstEdition property which we can only get from the server in that case.
// Therefore we need to at least refetch the changed piece from the server or on our case simply all // Therefore we need to at least refetch the changed piece from the server or on our case simply all
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
let notification = new GlobalNotificationModel('Editions successfully created', 'success', 10000); const notification = new GlobalNotificationModel(getLangText('Editions successfully created'), 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
getId() { getId() {
return {'id': this.state.piece.id}; return { 'id': this.state.piece.id };
}, },
getActions() { getActions() {
const { piece } = this.state; const { piece } = this.state;
const { currentUser } = this.props; const { currentUser } = this.props;
if (piece && piece.notifications && piece.notifications.length > 0) { if (piece.notifications && piece.notifications.length > 0) {
return ( return (
<ListRequestActions <ListRequestActions
currentUser={currentUser} currentUser={currentUser}
pieceOrEditions={piece} handleSuccess={this.loadPiece}
notifications={piece.notifications} notifications={piece.notifications}
handleSuccess={this.loadPiece} />); pieceOrEditions={piece} />
);
} else { } else {
return ( return (
<AclProxy <AclProxy
@ -236,12 +243,12 @@ let PieceContainer = React.createClass({
onPollingSuccess={this.handlePollingSuccess}/> onPollingSuccess={this.handlePollingSuccess}/>
<DeleteButton <DeleteButton
handleSuccess={this.handleDeleteSuccess} handleSuccess={this.handleDeleteSuccess}
piece={piece}/> piece={piece} />
<AclInformation <AclInformation
aim="button" aim="button"
verbs={['acl_share', 'acl_transfer', 'acl_create_editions', 'acl_loan', 'acl_delete', verbs={['acl_share', 'acl_transfer', 'acl_create_editions', 'acl_loan', 'acl_delete',
'acl_consign']} 'acl_consign']}
aclObject={piece.acl}/> aclObject={piece.acl} />
</AclButtonList> </AclButtonList>
</DetailProperty> </DetailProperty>
</AclProxy> </AclProxy>
@ -253,8 +260,8 @@ let PieceContainer = React.createClass({
const { currentUser, furtherDetailsType: FurtherDetailsType } = this.props; const { currentUser, furtherDetailsType: FurtherDetailsType } = this.props;
const { piece } = this.state; const { piece } = this.state;
if (piece && piece.id) { if (piece.id) {
setDocumentTitle([piece.artist_name, piece.title].join(', ')); setDocumentTitle(`${piece.artist_name}, ${piece.title}`);
return ( return (
<Piece <Piece
@ -264,7 +271,7 @@ let PieceContainer = React.createClass({
<div className="ascribe-detail-header"> <div className="ascribe-detail-header">
<hr className="hidden-print" style={{marginTop: 0}} /> <hr className="hidden-print" style={{marginTop: 0}} />
<h1 className="ascribe-detail-title">{piece.title}</h1> <h1 className="ascribe-detail-title">{piece.title}</h1>
<DetailProperty label="BY" value={piece.artist_name} /> <DetailProperty label="CREATED BY" value={piece.artist_name} />
<DetailProperty label="DATE" value={Moment(piece.date_created, 'YYYY-MM-DD').year() } /> <DetailProperty label="DATE" value={Moment(piece.date_created, 'YYYY-MM-DD').year() } />
{piece.num_editions > 0 ? <DetailProperty label="EDITIONS" value={ piece.num_editions } /> : null} {piece.num_editions > 0 ? <DetailProperty label="EDITIONS" value={ piece.num_editions } /> : null}
<hr/> <hr/>
@ -272,7 +279,7 @@ let PieceContainer = React.createClass({
} }
subheader={ subheader={
<div className="ascribe-detail-header"> <div className="ascribe-detail-header">
<DetailProperty label={getLangText('REGISTREE')} value={ piece.user_registered } /> <DetailProperty label={getLangText('ASCRIBED BY')} value={ piece.user_registered } />
<DetailProperty label={getLangText('ID')} value={ piece.bitcoin_id } ellipsis={true} /> <DetailProperty label={getLangText('ID')} value={ piece.bitcoin_id } ellipsis={true} />
<LicenseDetail license={piece.license_type} /> <LicenseDetail license={piece.license_type} />
</div> </div>

View File

@ -19,7 +19,7 @@ let CreateEditionsForm = React.createClass({
pieceId: React.PropTypes.number pieceId: React.PropTypes.number
}, },
getFormData(){ getFormData() {
return { return {
piece_id: parseInt(this.props.pieceId, 10) piece_id: parseInt(this.props.pieceId, 10)
}; };
@ -58,11 +58,12 @@ let CreateEditionsForm = React.createClass({
<input <input
type="number" type="number"
placeholder="(e.g. 32)" placeholder="(e.g. 32)"
min={1}/> min={1}
max={100} />
</Property> </Property>
</Form> </Form>
); );
} }
}); });
export default CreateEditionsForm; export default CreateEditionsForm;

View File

@ -156,7 +156,7 @@ let Form = React.createClass({
for(let ref in this.refs) { for(let ref in this.refs) {
if(this.refs[ref] && typeof this.refs[ref].handleSuccess === 'function'){ if(this.refs[ref] && typeof this.refs[ref].handleSuccess === 'function'){
this.refs[ref].handleSuccess(); this.refs[ref].handleSuccess(response);
} }
} }
this.setState({ this.setState({
@ -205,16 +205,15 @@ let Form = React.createClass({
}, },
getButtons() { getButtons() {
if (this.state.submitted){ if (this.state.submitted) {
return this.props.spinner; return this.props.spinner;
} }
if (this.props.buttons){ if (this.props.buttons !== undefined) {
return this.props.buttons; return this.props.buttons;
} }
let buttons = null;
if (this.state.edited && !this.props.disabled){ if (this.state.edited && !this.props.disabled) {
buttons = ( return (
<div className="row" style={{margin: 0}}> <div className="row" style={{margin: 0}}>
<p className="pull-right"> <p className="pull-right">
<Button <Button
@ -230,9 +229,9 @@ let Form = React.createClass({
</p> </p>
</div> </div>
); );
} else {
return null;
} }
return buttons;
}, },
getErrors() { getErrors() {

View File

@ -123,8 +123,7 @@ let ConsignForm = React.createClass({
<Property <Property
name='contract_agreement' name='contract_agreement'
label={getLangText('Consign Contract')} label={getLangText('Consign Contract')}
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputContractAgreementCheckbox <InputContractAgreementCheckbox
createPublicContractAgreement={createPublicContractAgreement} createPublicContractAgreement={createPublicContractAgreement}
email={email} /> email={email} />

View File

@ -48,8 +48,7 @@ let CopyrightAssociationForm = React.createClass({
<Property <Property
name="copyright_association" name="copyright_association"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle"
label={getLangText('Copyright Association')} label={getLangText('Copyright Association')}>
style={{paddingBottom: 0}}>
<select defaultValue={selectedState} name="contract"> <select defaultValue={selectedState} name="contract">
<option <option
name={0} name={0}
@ -77,4 +76,4 @@ let CopyrightAssociationForm = React.createClass({
} }
}); });
export default CopyrightAssociationForm; export default CopyrightAssociationForm;

View File

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

View File

@ -171,7 +171,7 @@ let LoanForm = React.createClass({
editable={!gallery} editable={!gallery}
overrideForm={!!gallery}> overrideForm={!!gallery}>
<input <input
value={gallery} defaultValue={gallery}
type="text" type="text"
placeholder={getLangText('Gallery/exhibition (optional)')}/> placeholder={getLangText('Gallery/exhibition (optional)')}/>
</Property> </Property>
@ -210,8 +210,7 @@ let LoanForm = React.createClass({
<Property <Property
name='contract_agreement' name='contract_agreement'
label={getLangText('Loan Contract')} label={getLangText('Loan Contract')}
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputContractAgreementCheckbox <InputContractAgreementCheckbox
createPublicContractAgreement={createPublicContractAgreement} createPublicContractAgreement={createPublicContractAgreement}
email={email} /> email={email} />

View File

@ -13,43 +13,48 @@ import InputTextAreaToggable from './input_textarea_toggable';
let PieceExtraDataForm = React.createClass({ let PieceExtraDataForm = React.createClass({
propTypes: { propTypes: {
pieceId: React.PropTypes.number, name: React.PropTypes.string.isRequired,
pieceId: React.PropTypes.number.isRequired,
convertLinks: React.PropTypes.bool,
editable: React.PropTypes.bool,
extraData: React.PropTypes.object, extraData: React.PropTypes.object,
handleSuccess: React.PropTypes.func, handleSuccess: React.PropTypes.func,
name: React.PropTypes.string, title: React.PropTypes.string
title: React.PropTypes.string,
editable: React.PropTypes.bool
}, },
getFormData() { getFormData() {
let extradata = {};
extradata[this.props.name] = this.refs.form.refs[this.props.name].state.value;
return { return {
extradata: extradata, extradata: {
[this.props.name]: this.refs.form.refs[this.props.name].state.value
},
piece_id: this.props.pieceId piece_id: this.props.pieceId
}; };
}, },
render() { render() {
let defaultValue = this.props.extraData[this.props.name] || ''; const { convertLinks, editable, extraData, handleSuccess, name, pieceId, title } = this.props;
if (defaultValue.length === 0 && !this.props.editable){ const defaultValue = (extraData && extraData[name]) || null;
if (!defaultValue && !editable) {
return null; return null;
} }
let url = requests.prepareUrl(ApiUrls.piece_extradata, {piece_id: this.props.pieceId});
return ( return (
<Form <Form
ref='form' ref='form'
url={url} disabled={!editable}
handleSuccess={this.props.handleSuccess}
getFormData={this.getFormData} getFormData={this.getFormData}
disabled={!this.props.editable}> handleSuccess={handleSuccess}
url={requests.prepareUrl(ApiUrls.piece_extradata, { piece_id: pieceId })}>
<Property <Property
name={this.props.name} name={name}
label={this.props.title}> label={title}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
convertLinks={convertLinks}
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder={getLangText('Fill in%s', ' ') + this.props.title} placeholder={getLangText('Fill in%s', ' ') + title}
required /> required />
</Property> </Property>
<hr /> <hr />

View File

@ -5,12 +5,16 @@ import React from 'react';
import Form from './form'; import Form from './form';
import Property from './property'; import Property from './property';
import InputFineUploader from './input_fineuploader'; import InputFineUploader from './input_fineuploader';
import UploadButton from '../ascribe_uploader/ascribe_upload_button/upload_button';
import FormSubmitButton from '../ascribe_buttons/form_submit_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 ApiUrls from '../../constants/api_urls';
import AppConstants from '../../constants/application_constants'; 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 { getLangText } from '../../utils/lang_utils';
import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils'; import { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils';
@ -164,7 +168,7 @@ let RegisterPieceForm = React.createClass({
createBlobRoutine={{ createBlobRoutine={{
url: ApiUrls.blob_digitalworks url: ApiUrls.blob_digitalworks
}} }}
validation={AppConstants.fineUploader.validation.registerWork} validation={validationTypes.registerWork}
setIsUploadReady={this.setIsUploadReady('digitalWorkKeyReady')} setIsUploadReady={this.setIsUploadReady('digitalWorkKeyReady')}
isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile} isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile}
isFineUploaderActive={isFineUploaderActive} isFineUploaderActive={isFineUploaderActive}
@ -190,9 +194,9 @@ let RegisterPieceForm = React.createClass({
fileClass: 'thumbnail' fileClass: 'thumbnail'
}} }}
validation={{ validation={{
itemLimit: AppConstants.fineUploader.validation.workThumbnail.itemLimit, itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.workThumbnail.sizeLimit, sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif'] allowedExtensions: validationParts.allowedExtensions.images
}} }}
setIsUploadReady={this.setIsUploadReady('thumbnailKeyReady')} setIsUploadReady={this.setIsUploadReady('thumbnailKeyReady')}
fileClassToUpload={{ fileClassToUpload={{
@ -200,9 +204,7 @@ let RegisterPieceForm = React.createClass({
plural: getLangText('Select representative images') plural: getLangText('Select representative images')
}} }}
isFineUploaderActive={isFineUploaderActive} isFineUploaderActive={isFineUploaderActive}
disabled={!isFineUploaderEditable} disabled={!isFineUploaderEditable} />
enableLocalHashing={enableLocalHashing}
uploadMethod={location.query.method} />
</Property> </Property>
<Property <Property
name='artist_name' name='artist_name'

View File

@ -128,8 +128,7 @@ let SendContractAgreementForm = React.createClass({
<Property <Property
name='appendix' name='appendix'
checkboxLabel={getLangText('Add appendix to the contract')} checkboxLabel={getLangText('Add appendix to the contract')}
expanded={false} expanded={false}>
style={{paddingBottom: 0}}>
<span>{getLangText('Appendix')}</span> <span>{getLangText('Appendix')}</span>
{/* We're using disabled on a form here as PropertyCollapsible currently {/* We're using disabled on a form here as PropertyCollapsible currently
does not support the disabled + overrideForm functionality */} does not support the disabled + overrideForm functionality */}

View File

@ -121,8 +121,7 @@ let SignupForm = React.createClass({
{children} {children}
<Property <Property
name="terms" name="terms"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputCheckbox> <InputCheckbox>
<span> <span>
{' ' + getLangText('I agree to the Terms of Service of ascribe') + ' '} {' ' + getLangText('I agree to the Terms of Service of ascribe') + ' '}

View File

@ -66,8 +66,7 @@ let PieceSubmitToPrizeForm = React.createClass({
</Property> </Property>
<Property <Property
name="terms" name="terms"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputCheckbox> <InputCheckbox>
<span> <span>
{' ' + getLangText('I agree to the Terms of Service the art price') + ' '} {' ' + getLangText('I agree to the Terms of Service the art price') + ' '}

View File

@ -156,7 +156,7 @@ const InputContractAgreementCheckbox = React.createClass({
return ( return (
<div <div
className="notification-contract-pdf" className="notification-contract-pdf"
style={{paddingBottom: '1em'}}> style={{paddingBottom: '0.25em'}}>
<embed <embed
className="embed-form" className="embed-form"
src={contractUrl} src={contractUrl}

View File

@ -17,10 +17,7 @@ let InputDate = React.createClass({
}, },
getInitialState() { getInitialState() {
return { return this.getStateFromMoment(this.props.defaultValue);
value: null,
value_moment: null
};
}, },
// InputDate needs to support setting a defaultValue from outside. // InputDate needs to support setting a defaultValue from outside.
@ -28,20 +25,30 @@ let InputDate = React.createClass({
// to the outer Property // to the outer Property
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if(!this.state.value && !this.state.value_moment && nextProps.defaultValue) { if(!this.state.value && !this.state.value_moment && nextProps.defaultValue) {
this.handleChange(this.props.defaultValue); this.handleChange(nextProps.defaultValue);
} }
}, },
handleChange(date) { getStateFromMoment(date) {
let formattedDate = date.format('YYYY-MM-DD'); const state = {};
this.setState({
value: formattedDate,
value_moment: date
});
if (date) {
state.value = date.format('YYYY-MM-DD');
state.value_moment = date;
}
return state;
},
handleChange(date) {
const newState = this.getStateFromMoment(date);
this.setState(newState);
// Propagate change up by faking event
this.props.onChange({ this.props.onChange({
target: { target: {
value: formattedDate value: newState.value
} }
}); });
}, },

View File

@ -28,11 +28,7 @@ const InputFineUploader = React.createClass({
createBlobRoutine: shape({ createBlobRoutine: shape({
url: string url: string
}), }),
validation: shape({ validation: ReactS3FineUploader.propTypes.validation,
itemLimit: number,
sizeLimit: string,
allowedExtensions: arrayOf(string)
}),
// isFineUploaderActive is used to lock react fine uploader in case // isFineUploaderActive is used to lock react fine uploader in case
// a user is actually not logged in already to prevent him from droping files // a user is actually not logged in already to prevent him from droping files

View File

@ -4,17 +4,20 @@ import React from 'react';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import { anchorize } from '../../utils/dom_utils';
let InputTextAreaToggable = React.createClass({ let InputTextAreaToggable = React.createClass({
propTypes: { propTypes: {
autoFocus: React.PropTypes.bool, autoFocus: React.PropTypes.bool,
disabled: React.PropTypes.bool, convertLinks: React.PropTypes.bool,
rows: React.PropTypes.number.isRequired,
required: React.PropTypes.bool,
defaultValue: React.PropTypes.string, defaultValue: React.PropTypes.string,
placeholder: React.PropTypes.string, disabled: React.PropTypes.bool,
onBlur: React.PropTypes.func, onBlur: React.PropTypes.func,
onChange: React.PropTypes.func onChange: React.PropTypes.func,
placeholder: React.PropTypes.string,
required: React.PropTypes.bool,
rows: React.PropTypes.number.isRequired
}, },
getInitialState() { getInitialState() {
@ -36,7 +39,7 @@ let InputTextAreaToggable = React.createClass({
componentDidUpdate() { componentDidUpdate() {
// If the initial value of state.value is null, we want to set props.defaultValue // If the initial value of state.value is null, we want to set props.defaultValue
// as a value. In all other cases TextareaAutosize.onChange is updating.handleChange already // as a value. In all other cases TextareaAutosize.onChange is updating.handleChange already
if(this.state.value === null && this.props.defaultValue) { if (this.state.value === null && this.props.defaultValue) {
this.setState({ this.setState({
value: this.props.defaultValue value: this.props.defaultValue
}); });
@ -49,28 +52,26 @@ let InputTextAreaToggable = React.createClass({
}, },
render() { render() {
let className = 'form-control ascribe-textarea'; const { convertLinks, disabled, onBlur, placeholder, required, rows } = this.props;
let textarea = null; const { value } = this.state;
if(!this.props.disabled) { if (!disabled) {
className = className + ' ascribe-textarea-editable'; return (
textarea = (
<TextareaAutosize <TextareaAutosize
ref='textarea' ref='textarea'
className={className} className='form-control ascribe-textarea ascribe-textarea-editable'
value={this.state.value} value={value}
rows={this.props.rows} rows={rows}
maxRows={10} maxRows={10}
required={this.props.required} required={required}
onChange={this.handleChange} onChange={this.handleChange}
onBlur={this.props.onBlur} onBlur={onBlur}
placeholder={this.props.placeholder} /> placeholder={placeholder} />
); );
} else { } else {
textarea = <pre className="ascribe-pre">{this.state.value}</pre>; // Can only convert links when not editable, as textarea does not support anchors
return <pre className="ascribe-pre">{convertLinks ? anchorize(value) : value}</pre>;
} }
return textarea;
} }
}); });

View File

@ -240,7 +240,17 @@ const Property = React.createClass({
}, },
handleCheckboxToggle() { handleCheckboxToggle() {
this.setExpanded(!this.state.expanded); const expanded = !this.state.expanded;
this.setExpanded(expanded);
// Reset the value to be the initial value when the checkbox is unticked since the
// user doesn't want to specify their own value.
if (!expanded) {
this.setState({
value: this.state.initialValue
});
}
}, },
renderChildren(style) { renderChildren(style) {

View File

@ -1,19 +1,20 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import ReactAddons from 'react/addons';
import Modal from 'react-bootstrap/lib/Modal'; import Modal from 'react-bootstrap/lib/Modal';
let ModalWrapper = React.createClass({ let ModalWrapper = React.createClass({
propTypes: { propTypes: {
trigger: React.PropTypes.element,
title: React.PropTypes.oneOfType([ title: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.arrayOf(React.PropTypes.element),
React.PropTypes.element, React.PropTypes.element,
React.PropTypes.string React.PropTypes.string
]).isRequired, ]).isRequired,
handleSuccess: React.PropTypes.func.isRequired,
handleCancel: React.PropTypes.func,
handleSuccess: React.PropTypes.func,
trigger: React.PropTypes.element,
children: React.PropTypes.oneOfType([ children: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.element), React.PropTypes.arrayOf(React.PropTypes.element),
React.PropTypes.element React.PropTypes.element
@ -38,14 +39,25 @@ let ModalWrapper = React.createClass({
}); });
}, },
handleCancel() {
if (typeof this.props.handleCancel === 'function') {
this.props.handleCancel();
}
this.hide();
},
handleSuccess(response) { handleSuccess(response) {
this.props.handleSuccess(response); if (typeof this.props.handleSuccess === 'function') {
this.props.handleSuccess(response);
}
this.hide(); this.hide();
}, },
renderChildren() { renderChildren() {
return ReactAddons.Children.map(this.props.children, (child) => { return React.Children.map(this.props.children, (child) => {
return ReactAddons.addons.cloneWithProps(child, { return React.cloneElement(child, {
handleSuccess: (response) => { handleSuccess: (response) => {
if (typeof child.props.handleSuccess === 'function') { if (typeof child.props.handleSuccess === 'function') {
child.props.handleSuccess(response); child.props.handleSuccess(response);
@ -60,14 +72,23 @@ let ModalWrapper = React.createClass({
render() { render() {
const { trigger, title } = this.props; const { trigger, title } = this.props;
// If the trigger component exists, we add the ModalWrapper's show() as its onClick method. // If the trigger component exists, we add the ModalWrapper's show() to its onClick method.
// The trigger component should, in most cases, be a button. // The trigger component should, in most cases, be a button.
const clonedTrigger = React.isValidElement(trigger) ? React.cloneElement(trigger, {onClick: this.show}) const clonedTrigger = React.isValidElement(trigger) ?
: null; React.cloneElement(trigger, {
onClick: (...params) => {
if (typeof trigger.props.onClick === 'function') {
trigger.props.onClick(...params);
}
this.show();
}
}) : null;
return ( return (
<span> <span>
{clonedTrigger} {clonedTrigger}
<Modal show={this.state.showModal} onHide={this.hide}> <Modal show={this.state.showModal} onHide={this.handleCancel}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
{title} {title}

View File

@ -84,6 +84,7 @@ let PieceListToolbarFilterWidget = React.createClass({
if (this.props.filterParams && this.props.filterParams.length) { if (this.props.filterParams && this.props.filterParams.length) {
return ( return (
<DropdownButton <DropdownButton
id="ascribe-piece-list-toolbar-filter-widget-dropdown"
pullRight={true} pullRight={true}
title={filterIcon} title={filterIcon}
className="ascribe-piece-list-toolbar-filter-widget"> className="ascribe-piece-list-toolbar-filter-widget">

View File

@ -45,7 +45,7 @@ let PieceListToolbarOrderWidget = React.createClass({
}, },
render() { render() {
let filterIcon = ( let orderIcon = (
<span> <span>
<span className="ascribe-icon icon-ascribe-sort" aria-hidden="true"></span> <span className="ascribe-icon icon-ascribe-sort" aria-hidden="true"></span>
<span style={this.isOrderActive()}>&middot;</span> <span style={this.isOrderActive()}>&middot;</span>
@ -55,9 +55,10 @@ let PieceListToolbarOrderWidget = React.createClass({
if (this.props.orderParams && this.props.orderParams.length) { if (this.props.orderParams && this.props.orderParams.length) {
return ( return (
<DropdownButton <DropdownButton
id="ascribe-piece-list-toolbar-order-widget-dropdown"
pullRight={true} pullRight={true}
title={filterIcon} className="ascribe-piece-list-toolbar-filter-widget"
className="ascribe-piece-list-toolbar-filter-widget"> title={orderIcon}>
<li style={{'textAlign': 'center'}}> <li style={{'textAlign': 'center'}}>
<em>{getLangText('Sort by')}:</em> <em>{getLangText('Sort by')}:</em>
</li> </li>

View File

@ -35,7 +35,7 @@ let AccountSettings = React.createClass({
getFormDataProfile(){ getFormDataProfile(){
return {'email': this.props.currentUser.email}; return {'email': this.props.currentUser.email};
}, },
render() { render() {
let content = <AscribeSpinner color='dark-blue' size='lg'/>; let content = <AscribeSpinner color='dark-blue' size='lg'/>;
let profile = null; let profile = null;
@ -78,8 +78,7 @@ let AccountSettings = React.createClass({
getFormData={this.getFormDataProfile}> getFormData={this.getFormDataProfile}>
<Property <Property
name="hash_locally" name="hash_locally"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputCheckbox <InputCheckbox
defaultChecked={this.props.currentUser.profile.hash_locally}> defaultChecked={this.props.currentUser.profile.hash_locally}>
<span> <span>

View File

@ -18,7 +18,7 @@ import AclProxy from '../acl_proxy';
import { getLangText } from '../../utils/lang_utils'; import { getLangText } from '../../utils/lang_utils';
import { setDocumentTitle } from '../../utils/dom_utils'; import { setDocumentTitle } from '../../utils/dom_utils';
import { mergeOptions, truncateTextAtCharIndex } from '../../utils/general_utils'; import { truncateTextAtCharIndex } from '../../utils/general_utils';
let ContractSettings = React.createClass({ let ContractSettings = React.createClass({
@ -53,11 +53,11 @@ let ContractSettings = React.createClass({
ContractListActions.removeContract(contract.id) ContractListActions.removeContract(contract.id)
.then((response) => { .then((response) => {
ContractListActions.fetchContractList(true); ContractListActions.fetchContractList(true);
let notification = new GlobalNotificationModel(response.notification, 'success', 4000); const notification = new GlobalNotificationModel(response.notification, 'success', 4000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}) })
.catch((err) => { .catch((err) => {
let notification = new GlobalNotificationModel(err, 'danger', 10000); const notification = new GlobalNotificationModel(err, 'danger', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}); });
}; };
@ -82,12 +82,11 @@ let ContractSettings = React.createClass({
if (publicContracts.length === 0) { if (publicContracts.length === 0) {
createPublicContractForm = ( createPublicContractForm = (
<CreateContractForm <CreateContractForm
isPublic={true}
fileClassToUpload={{ fileClassToUpload={{
singular: 'new contract', singular: 'new contract',
plural: 'new contracts' plural: 'new contracts'
}} }}
location={location} /> isPublic={true} />
); );
} }
@ -104,7 +103,7 @@ let ContractSettings = React.createClass({
{publicContracts.map((contract, i) => { {publicContracts.map((contract, i) => {
return ( return (
<ActionPanel <ActionPanel
key={i} key={contract.id}
title={contract.name} title={contract.name}
content={truncateTextAtCharIndex(contract.name, 120, '(...).pdf')} content={truncateTextAtCharIndex(contract.name, 120, '(...).pdf')}
buttons={ buttons={
@ -112,9 +111,7 @@ let ContractSettings = React.createClass({
<AclProxy <AclProxy
aclObject={whitelabel} aclObject={whitelabel}
aclName="acl_update_public_contract"> aclName="acl_update_public_contract">
<ContractSettingsUpdateButton <ContractSettingsUpdateButton contract={contract} />
contract={contract}
location={location}/>
</AclProxy> </AclProxy>
<a <a
className="btn btn-default btn-sm margin-left-2px" className="btn btn-default btn-sm margin-left-2px"
@ -140,16 +137,15 @@ let ContractSettings = React.createClass({
aclObject={currentUser.acl}> aclObject={currentUser.acl}>
<div> <div>
<CreateContractForm <CreateContractForm
isPublic={false} fileClassToUpload={{
fileClassToUpload={{ singular: getLangText('new contract'),
singular: getLangText('new contract'), plural: getLangText('new contracts')
plural: getLangText('new contracts') }}
}} isPublic={false} />
location={location}/>
{privateContracts.map((contract, i) => { {privateContracts.map((contract, i) => {
return ( return (
<ActionPanel <ActionPanel
key={i} key={contract.id}
title={contract.name} title={contract.name}
content={truncateTextAtCharIndex(contract.name, 120, '(...).pdf')} content={truncateTextAtCharIndex(contract.name, 120, '(...).pdf')}
buttons={ buttons={
@ -157,9 +153,7 @@ let ContractSettings = React.createClass({
<AclProxy <AclProxy
aclObject={whitelabel} aclObject={whitelabel}
aclName="acl_update_private_contract"> aclName="acl_update_private_contract">
<ContractSettingsUpdateButton <ContractSettingsUpdateButton contract={contract} />
contract={contract}
location={location}/>
</AclProxy> </AclProxy>
<a <a
className="btn btn-default btn-sm margin-left-2px" className="btn btn-default btn-sm margin-left-2px"

View File

@ -2,17 +2,18 @@
import React from 'react'; 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 ContractListActions from '../../actions/contract_list_actions';
import GlobalNotificationModel from '../../models/global_notification_model'; import GlobalNotificationModel from '../../models/global_notification_model';
import GlobalNotificationActions from '../../actions/global_notification_actions'; 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 { formSubmissionValidation } from '../ascribe_uploader/react_s3_fine_uploader_utils';
import { getCookie } from '../../utils/fetch_api_utils'; import { getCookie } from '../../utils/fetch_api_utils';
import { getLangText } from '../../utils/lang_utils'; import { getLangText } from '../../utils/lang_utils';
@ -24,71 +25,75 @@ let ContractSettingsUpdateButton = React.createClass({
}, },
submitFile(file) { submitFile(file) {
let contract = this.props.contract;
// override the blob with the key's value // override the blob with the key's value
contract.blob = file.key; const contract = Object.assign(this.props.contract, { blob: file.key });
// send it to the server // send it to the server
ContractListActions ContractListActions
.changeContract(contract) .changeContract(contract)
.then((res) => { .then((res) => {
// Display feedback to the user // Display feedback to the user
let notification = new GlobalNotificationModel(getLangText('Contract %s successfully updated', res.name), 'success', 5000); const notification = new GlobalNotificationModel(getLangText('Contract %s successfully updated', contract.name), 'success', 5000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
// and refresh the contract list to get the updated contracs // and refresh the contract list to get the updated contracs
return ContractListActions.fetchContractList(true); return ContractListActions
}) .fetchContractList(true)
.then(() => { // Also, reset the fineuploader component if fetch is successful so that the user can again 'update' his contract
// Also, reset the fineuploader component so that the user can again 'update' his contract .then(this.refs.fineuploader.reset)
this.refs.fineuploader.reset(); .catch((err) => {
}) const notification = new GlobalNotificationModel(getLangText('Latest contract failed to load'), 'danger', 5000);
.catch((err) => { GlobalNotificationActions.appendGlobalNotification(notification);
console.logGlobal(err);
let notification = new GlobalNotificationModel(getLangText('Contract could not be updated'), 'success', 5000); return Promise.reject(err);
});
}, (err) => {
const notification = new GlobalNotificationModel(getLangText('Contract could not be updated'), 'danger', 5000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
});
return Promise.reject(err);
})
.catch(console.logGlobal);
}, },
render() { render() {
return ( return (
<ReactS3FineUploader <ReactS3FineUploader
fileInputElement={UploadButton()} ref='fineuploader'
keyRoutine={{ fileInputElement={UploadButton({ showLabel: false })}
url: AppConstants.serverUrl + 's3/key/', keyRoutine={{
fileClass: 'contract' url: AppConstants.serverUrl + 's3/key/',
}} fileClass: 'contract'
createBlobRoutine={{ }}
url: ApiUrls.blob_contracts createBlobRoutine={{
}} url: ApiUrls.blob_contracts
validation={{ }}
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit, validation={{
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit, itemLimit: validationTypes.registerWork.itemLimit,
allowedExtensions: ['pdf'] sizeLimit: validationTypes.additionalData.sizeLimit,
}} allowedExtensions: ['pdf']
setIsUploadReady={() =>{/* So that ReactS3FineUploader is not complaining */}} }}
signature={{ setIsUploadReady={() =>{/* So that ReactS3FineUploader is not complaining */}}
endpoint: AppConstants.serverUrl + 's3/signature/', signature={{
customHeaders: { endpoint: AppConstants.serverUrl + 's3/signature/',
'X-CSRFToken': getCookie(AppConstants.csrftoken) customHeaders: {
} 'X-CSRFToken': getCookie(AppConstants.csrftoken)
}} }
deleteFile={{ }}
enabled: true, deleteFile={{
method: 'DELETE', enabled: true,
endpoint: AppConstants.serverUrl + 's3/delete', method: 'DELETE',
customHeaders: { endpoint: AppConstants.serverUrl + 's3/delete',
'X-CSRFToken': getCookie(AppConstants.csrftoken) customHeaders: {
} 'X-CSRFToken': getCookie(AppConstants.csrftoken)
}} }
fileClassToUpload={{ }}
singular: getLangText('UPDATE'), fileClassToUpload={{
plural: getLangText('UPDATE') singular: getLangText('UPDATE'),
}} plural: getLangText('UPDATE')
isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile} }}
submitFile={this.submitFile} /> isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile}
submitFile={this.submitFile} />
); );
} }
}); });

View File

@ -34,7 +34,6 @@ let WebhookSettings = React.createClass({
componentDidMount() { componentDidMount() {
WebhookStore.listen(this.onChange); WebhookStore.listen(this.onChange);
WebhookActions.fetchWebhooks(); WebhookActions.fetchWebhooks();
WebhookActions.fetchWebhookEvents();
}, },
componentWillUnmount() { componentWillUnmount() {
@ -49,7 +48,7 @@ let WebhookSettings = React.createClass({
return (event) => { return (event) => {
WebhookActions.removeWebhook(webhookId); WebhookActions.removeWebhook(webhookId);
let notification = new GlobalNotificationModel(getLangText('Webhook deleted'), 'success', 2000); const notification = new GlobalNotificationModel(getLangText('Webhook deleted'), 'success', 2000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}; };
}, },
@ -57,16 +56,16 @@ let WebhookSettings = React.createClass({
handleCreateSuccess() { handleCreateSuccess() {
this.refs.webhookCreateForm.reset(); this.refs.webhookCreateForm.reset();
WebhookActions.fetchWebhooks(true); WebhookActions.fetchWebhooks(true);
let notification = new GlobalNotificationModel(getLangText('Webhook successfully created'), 'success', 5000);
const notification = new GlobalNotificationModel(getLangText('Webhook successfully created'), 'success', 5000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
getWebhooks(){ getWebhooks() {
let content = <AscribeSpinner color='dark-blue' size='lg'/>;
if (this.state.webhooks) { if (this.state.webhooks) {
content = this.state.webhooks.map(function(webhook, i) { return this.state.webhooks.map(function(webhook, i) {
const event = webhook.event.split('.')[0]; const event = webhook.event.split('.')[0];
return ( return (
<ActionPanel <ActionPanel
name={webhook.event} name={webhook.event}
@ -91,11 +90,14 @@ let WebhookSettings = React.createClass({
</button> </button>
</div> </div>
</div> </div>
}/> } />
); );
}, this); }, this);
} else {
return (
<AscribeSpinner color='dark-blue' size='lg'/>
);
} }
return content;
}, },
getEvents() { getEvents() {
@ -110,18 +112,18 @@ let WebhookSettings = React.createClass({
<option <option
name={i} name={i}
key={i} key={i}
value={ event + '.webhook' }> value={event + '.webhook'}>
{ event.toUpperCase() } {event.toUpperCase()}
</option> </option>
); );
})} })}
</select> </select>
</Property>); </Property>);
} else {
return null;
} }
return null;
}, },
render() { render() {
return ( return (
<CollapsibleParagraph <CollapsibleParagraph
@ -138,20 +140,19 @@ let WebhookSettings = React.createClass({
a target url. a target url.
</p> </p>
</div> </div>
<AclProxy <AclProxy show={this.state.webhookEvents && this.state.webhookEvents.length}>
show={this.state.webhookEvents && this.state.webhookEvents.length}>
<Form <Form
ref="webhookCreateForm" ref="webhookCreateForm"
url={ApiUrls.webhooks} url={ApiUrls.webhooks}
handleSuccess={this.handleCreateSuccess}> handleSuccess={this.handleCreateSuccess}>
{ this.getEvents() } {this.getEvents()}
<Property <Property
name='target' name='target'
label={getLangText('Redirect Url')}> label={getLangText('Redirect Url')}>
<input <input
type="text" type="text"
placeholder={getLangText('Enter the url to be triggered')} placeholder={getLangText('Enter the url to be triggered')}
required/> required />
</Property> </Property>
<hr /> <hr />
</Form> </Form>
@ -162,4 +163,4 @@ let WebhookSettings = React.createClass({
} }
}); });
export default WebhookSettings; export default WebhookSettings;

View File

@ -2,6 +2,8 @@
import React from 'react'; import React from 'react';
import FacebookHandler from '../../third_party/facebook_handler';
import AppConstants from '../../constants/application_constants'; import AppConstants from '../../constants/application_constants';
import { InjectInHeadUtils } from '../../utils/inject_utils'; import { InjectInHeadUtils } from '../../utils/inject_utils';
@ -17,24 +19,40 @@ let FacebookShareButton = React.createClass({
}; };
}, },
componentDidMount() { getInitialState() {
/** return FacebookHandler.getState();
* Ideally we would only use FB.XFBML.parse() on the component that we're
* mounting, but doing this when we first load the FB sdk causes unpredictable behaviour.
* The button sometimes doesn't get initialized, likely because FB hasn't properly
* been initialized yet.
*
* To circumvent this, we always have the sdk parse the entire DOM on the initial load
* (see FacebookHandler) and then use FB.XFBML.parse() on the mounting component later.
*/
InjectInHeadUtils
.inject(AppConstants.facebook.sdkUrl)
.then(() => { FB.XFBML.parse(this.refs.fbShareButton.getDOMNode().parentElement) });
}, },
shouldComponentUpdate(nextProps) { componentDidMount() {
return this.props.type !== nextProps.type; FacebookHandler.listen(this.onChange);
this.loadFacebook();
},
shouldComponentUpdate(nextProps, nextState) {
// Don't update if the props haven't changed or the FB SDK loading status is still the same
return this.props.type !== nextProps.type || nextState.loaded !== this.state.loaded;
},
componentDidUpdate() {
// If the component changes, we need to reparse the share button's XFBML.
// To prevent cases where the Facebook SDK hasn't been loaded yet at this stage,
// let's make sure that it's injected before trying to reparse.
this.loadFacebook();
},
onChange(state) {
this.setState(state);
},
loadFacebook() {
InjectInHeadUtils
.inject(AppConstants.facebook.sdkUrl)
.then(() => {
if (this.state.loaded) {
FB.XFBML.parse(this.refs.fbShareButton.getDOMNode().parent)
}
});
}, },
render() { render() {

View File

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

View File

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

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { displayValidProgressFilesFilter } from '../react_s3_fine_uploader_utils'; import { displayValidProgressFilesFilter } from '../react_s3_fine_uploader_utils';
import { getLangText } from '../../../utils/lang_utils'; import { getLangText } from '../../../utils/lang_utils';
@ -9,7 +10,7 @@ import { truncateTextAtCharIndex } from '../../../utils/general_utils';
const { func, array, bool, shape, string } = React.PropTypes; const { func, array, bool, shape, string } = React.PropTypes;
export default function UploadButton({ className = 'btn btn-default btn-sm' } = {}) { export default function UploadButton({ className = 'btn btn-default btn-sm', showLabel = true } = {}) {
return React.createClass({ return React.createClass({
displayName: 'UploadButton', displayName: 'UploadButton',
@ -119,28 +120,28 @@ export default function UploadButton({ className = 'btn btn-default btn-sm' } =
}, },
getUploadedFileLabel() { getUploadedFileLabel() {
const uploadedFile = this.getUploadedFile(); if (showLabel) {
const uploadingFiles = this.getUploadingFiles(); const uploadedFile = this.getUploadedFile();
const uploadingFiles = this.getUploadingFiles();
if(uploadingFiles.length) { if (uploadingFiles.length) {
return ( return (
<span> <span>
{' ' + truncateTextAtCharIndex(uploadingFiles[0].name, 40) + ' '} {' ' + truncateTextAtCharIndex(uploadingFiles[0].name, 40) + ' '}
[<a onClick={this.onClickRemove}>{getLangText('cancel upload')}</a>] [<a onClick={this.onClickRemove}>{getLangText('cancel upload')}</a>]
</span> </span>
); );
} else if(uploadedFile) { } else if (uploadedFile) {
return ( return (
<span> <span>
<span className='ascribe-icon icon-ascribe-ok'/> <span className='ascribe-icon icon-ascribe-ok'/>
{' ' + truncateTextAtCharIndex(uploadedFile.name, 40) + ' '} {' ' + truncateTextAtCharIndex(uploadedFile.name, 40) + ' '}
[<a onClick={this.onClickRemove}>{getLangText('remove')}</a>] [<a onClick={this.onClickRemove}>{getLangText('remove')}</a>]
</span> </span>
); );
} else { } else {
return ( return <span>{getLangText('No file chosen')}</span>;
<span>{getLangText('No file chosen')}</span> }
);
} }
}, },
@ -158,7 +159,7 @@ export default function UploadButton({ className = 'btn btn-default btn-sm' } =
* Therefore the wrapping component needs to be an `anchor` tag instead of a `button` * Therefore the wrapping component needs to be an `anchor` tag instead of a `button`
*/ */
return ( return (
<div className="upload-button-wrapper"> <div className={classNames('ascribe-upload-button', {'ascribe-upload-button-has-label': showLabel})}>
{/* {/*
The button needs to be of `type="button"` as it would The button needs to be of `type="button"` as it would
otherwise submit the form its in. otherwise submit the form its in.

View File

@ -13,9 +13,9 @@ import GlobalNotificationActions from '../../actions/global_notification_actions
import AppConstants from '../../constants/application_constants'; import AppConstants from '../../constants/application_constants';
import { computeHashOfFile } from '../../utils/file_utils';
import { displayValidFilesFilter, transformAllowedExtensionsToInputAcceptProp } from './react_s3_fine_uploader_utils'; import { displayValidFilesFilter, transformAllowedExtensionsToInputAcceptProp } from './react_s3_fine_uploader_utils';
import { getCookie } from '../../utils/fetch_api_utils'; import { getCookie } from '../../utils/fetch_api_utils';
import { computeHashOfFile, extractFileExtensionFromString } from '../../utils/file_utils';
import { getLangText } from '../../utils/lang_utils'; import { getLangText } from '../../utils/lang_utils';
@ -36,17 +36,11 @@ const ReactS3FineUploader = React.createClass({
keyRoutine: shape({ keyRoutine: shape({
url: string, url: string,
fileClass: string, fileClass: string,
pieceId: oneOfType([ pieceId: number
string,
number
])
}), }),
createBlobRoutine: shape({ createBlobRoutine: shape({
url: string, url: string,
pieceId: oneOfType([ pieceId: number
string,
number
])
}), }),
handleChangedFile: func, // is for when a file is dropped or selected handleChangedFile: func, // is for when a file is dropped or selected
submitFile: func, // is for when a file has been successfully uploaded, TODO: rename to handleSubmitFile submitFile: func, // is for when a file has been successfully uploaded, TODO: rename to handleSubmitFile
@ -97,7 +91,7 @@ const ReactS3FineUploader = React.createClass({
}), }),
validation: shape({ validation: shape({
itemLimit: number, itemLimit: number,
sizeLimit: string, sizeLimit: number,
allowedExtensions: arrayOf(string) allowedExtensions: arrayOf(string)
}), }),
messages: shape({ messages: shape({
@ -284,22 +278,6 @@ const ReactS3FineUploader = React.createClass({
this.setState(this.getInitialState()); 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) { requestKey(fileId) {
let filename = this.state.uploader.getName(fileId); let filename = this.state.uploader.getName(fileId);
let uuid = this.state.uploader.getUuid(fileId); let uuid = this.state.uploader.getUuid(fileId);
@ -390,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) { setThumbnailForFileId(fileId, url) {
const { filesToUpload } = this.state; const { filesToUpload } = this.state;
@ -512,34 +591,6 @@ const ReactS3FineUploader = React.createClass({
GlobalNotificationActions.appendGlobalNotification(notification); 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) { onCancel(id) {
// when a upload is canceled, we need to update this components file array // when a upload is canceled, we need to update this components file array
this.setStatusOfFile(id, 'canceled') this.setStatusOfFile(id, 'canceled')
@ -676,6 +727,13 @@ const ReactS3FineUploader = React.createClass({
this.cancelUploads(fileId); 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) { handlePauseFile(fileId) {
if(this.state.uploader.pauseUpload(fileId)) { if(this.state.uploader.pauseUpload(fileId)) {
this.setStatusOfFile(fileId, 'paused'); this.setStatusOfFile(fileId, 'paused');
@ -704,15 +762,8 @@ const ReactS3FineUploader = React.createClass({
return; return;
} }
// validate each submitted file if it fits the file size // Select only the submitted files that fit the file size and allowed extensions
let validFiles = []; files = this.selectValidFiles(files);
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;
// if multiple is set to false and user drops multiple files into the dropzone, // 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 // take the first one and notify user that only one file can be submitted
@ -823,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. // 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 // 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 // be able to execute actions on a currently uploading file we need to exactly sync the file list
@ -899,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() { render() {
const { const {
multiple, multiple,

View File

@ -32,7 +32,7 @@ let CoaVerifyContainer = React.createClass({
return ( return (
<div className="ascribe-login-wrapper"> <div className="ascribe-login-wrapper">
<br/> <br />
<div className="ascribe-login-text ascribe-login-header"> <div className="ascribe-login-text ascribe-login-header">
{getLangText('Verify your Certificate of Authenticity')} {getLangText('Verify your Certificate of Authenticity')}
</div> </div>
@ -42,7 +42,7 @@ let CoaVerifyContainer = React.createClass({
signature={signature}/> signature={signature}/>
<br /> <br />
<br /> <br />
{getLangText('ascribe is using the following public key for verification')}: {getLangText('ascribe is using the following public key for verification')}:
<br /> <br />
<pre> <pre>
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
@ -75,46 +75,44 @@ let CoaVerifyForm = React.createClass({
const { message, signature } = this.props; const { message, signature } = this.props;
return ( return (
<div> <Form
<Form url={ApiUrls.coa_verify}
url={ApiUrls.coa_verify} handleSuccess={this.handleSuccess}
handleSuccess={this.handleSuccess} buttons={
buttons={ <button
<button type="submit"
type="submit" className="btn btn-default btn-wide">
className="btn btn-default btn-wide"> {getLangText('Verify your Certificate of Authenticity')}
{getLangText('Verify your Certificate of Authenticity')} </button>
</button>} }
spinner={ spinner={
<span className="btn btn-default btn-wide btn-spinner"> <span className="btn btn-default btn-wide btn-spinner">
<AscribeSpinner color="dark-blue" size="md" /> <AscribeSpinner color="dark-blue" size="md" />
</span> </span>
}> }>
<Property <Property
name='message' name='message'
label={getLangText('Message')}> label={getLangText('Message')}>
<input <input
type="text" type="text"
placeholder={getLangText('Copy paste the message on the bottom of your Certificate of Authenticity')} placeholder={getLangText('Copy paste the message on the bottom of your Certificate of Authenticity')}
autoComplete="on" autoComplete="on"
defaultValue={message} defaultValue={message}
name="username" required />
required/> </Property>
</Property> <Property
<Property name='signature'
name='signature' label="Signature"
label="Signature" editable={true}
editable={true} overrideForm={true}>
overrideForm={true}> <InputTextAreaToggable
<InputTextAreaToggable rows={3}
rows={3} placeholder={getLangText('Copy paste the signature on the bottom of your Certificate of Authenticity')}
placeholder={getLangText('Copy paste the signature on the bottom of your Certificate of Authenticity')} defaultValue={signature}
defaultValue={signature} required />
required/> </Property>
</Property> <hr />
<hr /> </Form>
</Form>
</div>
); );
} }
}); });

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import { History } from 'react-router';
import { getLangText } from '../utils/lang_utils'; import { getLangText } from '../utils/lang_utils';
@ -17,12 +18,25 @@ let ErrorNotFoundPage = React.createClass({
location: React.PropTypes.object location: React.PropTypes.object
}, },
mixins: [History],
getDefaultProps() { getDefaultProps() {
return { return {
message: getLangText("Oops, the page you are looking for doesn't exist.") message: getLangText("Oops, the page you are looking for doesn't exist.")
}; };
}, },
componentDidMount() {
// The previous page, if any, is the second item in the locationQueue
const { locationQueue: [ , previousPage ] } = this.history;
if (previousPage) {
console.logGlobal('Page not found', {
previousPath: previousPage.pathname
});
}
},
render() { render() {
return ( return (
<div className="row"> <div className="row">

View File

@ -5,8 +5,12 @@ import React from 'react';
import { getLangText } from '../utils/lang_utils'; import { getLangText } from '../utils/lang_utils';
let Footer = React.createClass({ let Footer = React.createClass({
propTypes: {
activeRoute: React.PropTypes.object.isRequired
},
render() { render() {
return ( return !this.props.activeRoute.hideFooter ? (
<div className="ascribe-footer hidden-print"> <div className="ascribe-footer hidden-print">
<p className="ascribe-sub-sub-statement"> <p className="ascribe-sub-sub-statement">
<br /> <br />
@ -24,7 +28,7 @@ let Footer = React.createClass({
<a href="https://www.linkedin.com/company/4816284?trk=vsrp_companies_res_name&trkInfo=VSRPsearchId%3A122827941425632318075%2CVSRPtargetId%3A4816284%2CVSRPcmpt%3Aprimary" className="social social-linked-in" target="_blank"></a> <a href="https://www.linkedin.com/company/4816284?trk=vsrp_companies_res_name&trkInfo=VSRPsearchId%3A122827941425632318075%2CVSRPtargetId%3A4816284%2CVSRPcmpt%3Aprimary" className="social social-linked-in" target="_blank"></a>
</p> </p>
</div> </div>
); ) : null;
} }
}); });

View File

@ -1,43 +0,0 @@
'use strict';
import React from 'react';
let GlobalAction = React.createClass({
propTypes: {
requestActions: React.PropTypes.object
},
render() {
let pieceActions = null;
if (this.props.requestActions && this.props.requestActions.pieces){
pieceActions = this.props.requestActions.pieces.map((item) => {
return (
<div className="ascribe-global-action">
{item}
</div>);
});
}
let editionActions = null;
if (this.props.requestActions && this.props.requestActions.editions){
editionActions = Object.keys(this.props.requestActions.editions).map((pieceId) => {
return this.props.requestActions.editions[pieceId].map((item) => {
return (
<div className="ascribe-global-action">
{item}
</div>);
});
});
}
if (pieceActions || editionActions) {
return (
<div className="ascribe-global-action-wrapper">
{pieceActions}
{editionActions}
</div>);
}
return null;
}
});
export default GlobalAction;

View File

@ -16,6 +16,8 @@ import LinkContainer from 'react-router-bootstrap/lib/LinkContainer';
import EventActions from '../actions/event_actions'; import EventActions from '../actions/event_actions';
import PieceListStore from '../stores/piece_list_store';
import AclProxy from './acl_proxy'; import AclProxy from './acl_proxy';
import HeaderNotifications from './header_notification'; import HeaderNotifications from './header_notification';
import HeaderNotificationDebug from './header_notification_debug'; import HeaderNotificationDebug from './header_notification_debug';
@ -27,12 +29,21 @@ import { constructHead } from '../utils/dom_utils';
let Header = React.createClass({ let Header = React.createClass({
propTypes: { propTypes: {
// Provided from AscribeApp
currentUser: React.PropTypes.object.isRequired, currentUser: React.PropTypes.object.isRequired,
routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
whitelabel: React.PropTypes.object.isRequired whitelabel: React.PropTypes.object.isRequired
}, },
getInitialState() {
return PieceListStore.getState();
},
componentDidMount() { 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);
// react-bootstrap 0.25.1 has a bug in which it doesn't // react-bootstrap 0.25.1 has a bug in which it doesn't
// close the mobile expanded navigation after a click by itself. // close the mobile expanded navigation after a click by itself.
// To get rid of this, we set the state of the component ourselves. // To get rid of this, we set the state of the component ourselves.
@ -40,9 +51,14 @@ let Header = React.createClass({
}, },
componentWillUnmount() { componentWillUnmount() {
PieceListStore.unlisten(this.onChange);
//history.unlisten(this.onRouteChange); //history.unlisten(this.onRouteChange);
}, },
onChange(state) {
this.setState(state);
},
getLogo() { getLogo() {
const { whitelabel } = this.props; const { whitelabel } = this.props;
@ -115,7 +131,9 @@ let Header = React.createClass({
}, },
render() { render() {
const { currentUser, routes } = this.props; const { currentUser, routes } = this.props;
const { unfilteredPieceListCount } = this.state;
let account; let account;
let signup; let signup;
let navRoutesLinks; let navRoutesLinks;
@ -124,6 +142,7 @@ let Header = React.createClass({
account = ( account = (
<DropdownButton <DropdownButton
ref='dropdownbutton' ref='dropdownbutton'
id="nav-route-user-dropdown"
eventKey="1" eventKey="1"
title={currentUser.username}> title={currentUser.username}>
<LinkContainer <LinkContainer
@ -153,12 +172,18 @@ let Header = React.createClass({
</DropdownButton> </DropdownButton>
); );
// 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 = (
<NavRoutesLinks <NavRoutesLinks
routes={routes}
userAcl={currentUser.acl}
navbar navbar
right /> right
hasPieces={!!unfilteredPieceListCount}
routes={routes}
userAcl={currentUser.acl} />
); );
} else { } else {
account = ( account = (

View File

@ -126,6 +126,7 @@ let HeaderNotifications = React.createClass({
<Nav navbar right> <Nav navbar right>
<DropdownButton <DropdownButton
ref='dropdownbutton' ref='dropdownbutton'
id="header-notification-dropdown"
eventKey="1" eventKey="1"
title={ title={
<span> <span>

View File

@ -11,14 +11,33 @@ import AclProxy from './acl_proxy';
import { sanitizeList } from '../utils/general_utils'; import { sanitizeList } from '../utils/general_utils';
const DISABLE_ENUM = ['hasPieces', 'noPieces'];
let NavRoutesLinks = React.createClass({ let NavRoutesLinks = React.createClass({
propTypes: { propTypes: {
hasPieces: React.PropTypes.bool,
routes: React.PropTypes.arrayOf(React.PropTypes.object), routes: React.PropTypes.arrayOf(React.PropTypes.object),
userAcl: 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 * 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 * 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 * @return {Array} Array of ReactElements that can be displayed to the user
*/ */
extractLinksFromRoutes(node, userAcl, i) { extractLinksFromRoutes(node, userAcl, i) {
if(!node) { if (!node) {
return; return;
} }
let links = node.childRoutes.map((child, j) => { const links = node.childRoutes.map((child, j) => {
let childrenFn = null; const { aclName, disableOn, headerTitle, path, childRoutes } = child;
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++);
}
// We validate if the user has set the title correctly, // We validate if the user has set the title correctly,
// otherwise we're not going to render his route // 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, // if there is an aclName present on the route definition,
// we evaluate it against the user's acl // we evaluate it against the user's acl
if(aclName && typeof aclName !== 'undefined') { if (aclName && typeof aclName !== 'undefined') {
return ( return (
<AclProxy <AclProxy
key={j} key={j}
aclName={aclName} aclName={aclName}
aclObject={this.props.userAcl}> aclObject={this.props.userAcl}>
<NavRoutesLinksLink <NavRoutesLinksLink {...navLinkProps} />
headerTitle={headerTitle}
routePath={'/' + path}
depth={i}
children={childrenFn}/>
</AclProxy> </AclProxy>
); );
} else { } else {
return ( return (
<NavRoutesLinksLink <NavRoutesLinksLink
key={j} key={j}
headerTitle={headerTitle} {...navLinkProps} />
routePath={'/' + path}
depth={i}
children={childrenFn}/>
); );
} }
} else { } else {
@ -84,7 +105,7 @@ let NavRoutesLinks = React.createClass({
}, },
render() { render() {
let {routes, userAcl} = this.props; const {routes, userAcl} = this.props;
return ( return (
<Nav {...this.props}> <Nav {...this.props}>
@ -94,4 +115,4 @@ let NavRoutesLinks = React.createClass({
} }
}); });
export default NavRoutesLinks; export default NavRoutesLinks;

View File

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

View File

@ -36,7 +36,10 @@ let PieceList = React.createClass({
accordionListItemType: React.PropTypes.func, accordionListItemType: React.PropTypes.func,
bulkModalButtonListType: React.PropTypes.func, bulkModalButtonListType: React.PropTypes.func,
canLoadPieceList: React.PropTypes.bool, canLoadPieceList: React.PropTypes.bool,
redirectTo: React.PropTypes.string, redirectTo: React.PropTypes.shape({
pathname: React.PropTypes.string,
query: React.PropTypes.object
}),
shouldRedirect: React.PropTypes.func, shouldRedirect: React.PropTypes.func,
customSubmitButton: React.PropTypes.element, customSubmitButton: React.PropTypes.element,
customThumbnailPlaceholder: React.PropTypes.func, customThumbnailPlaceholder: React.PropTypes.func,
@ -59,7 +62,6 @@ let PieceList = React.createClass({
accordionListItemType: AccordionListItemWallet, accordionListItemType: AccordionListItemWallet,
bulkModalButtonListType: AclButtonList, bulkModalButtonListType: AclButtonList,
canLoadPieceList: true, canLoadPieceList: true,
orderParams: ['artist_name', 'title'],
filterParams: [{ filterParams: [{
label: getLangText('Show works I can'), label: getLangText('Show works I can'),
items: [ items: [
@ -67,7 +69,13 @@ let PieceList = React.createClass({
'acl_consign', 'acl_consign',
'acl_create_editions' 'acl_create_editions'
] ]
}] }],
orderParams: ['artist_name', 'title'],
redirectTo: {
pathname: '/register_piece',
query: null
},
shouldRedirect: (pieceCount) => !pieceCount
}; };
}, },
@ -124,10 +132,16 @@ let PieceList = React.createClass({
const { location: { query }, redirectTo, shouldRedirect } = this.props; const { location: { query }, redirectTo, shouldRedirect } = this.props;
const { unfilteredPieceListCount } = this.state; const { unfilteredPieceListCount } = this.state;
if (redirectTo && unfilteredPieceListCount === 0 && if (redirectTo && redirectTo.pathname &&
(typeof shouldRedirect === 'function' && shouldRedirect(unfilteredPieceListCount))) { (typeof shouldRedirect === 'function' && shouldRedirect(unfilteredPieceListCount))) {
// FIXME: hack to redirect out of the dispatch cycle // FIXME: hack to redirect out of the dispatch cycle
window.setTimeout(() => this.history.push({ query, pathname: redirectTo }), 0); window.setTimeout(() => this.history.push({
// Occasionally, the back end also sets query parameters for Onion.
// We need to consider this by merging all passed query parameters, as we'll
// otherwise end up in a 404 screen
query: Object.assign({}, query, redirectTo.query),
pathname: redirectTo.pathname
}), 0);
} }
}, },
@ -201,13 +215,14 @@ let PieceList = React.createClass({
this.state.pieceList this.state.pieceList
.forEach((piece) => { .forEach((piece) => {
// but only if they're actually open // but only if they're actually open
if(this.state.isEditionListOpenForPieceId[piece.id].show) { const isEditionListOpenForPiece = this.state.isEditionListOpenForPieceId[piece.id];
if (isEditionListOpenForPiece && isEditionListOpenForPiece.show) {
EditionListActions.refreshEditionList({ EditionListActions.refreshEditionList({
pieceId: piece.id, pieceId: piece.id,
filterBy filterBy
}); });
} }
}); });
}); });
@ -217,15 +232,15 @@ let PieceList = React.createClass({
}, },
applyOrderBy(orderBy) { applyOrderBy(orderBy) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, page, pageSize, search } = this.state;
orderBy, this.state.orderAsc, this.state.filterBy); PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
}, },
loadPieceList({ page, filterBy = this.state.filterBy, search = this.state.search }) { loadPieceList({ page, filterBy = this.state.filterBy, search = this.state.search }) {
const { orderAsc, pageSize } = this.state;
const orderBy = this.state.orderBy || this.props.orderBy; const orderBy = this.state.orderBy || this.props.orderBy;
return PieceListActions.fetchPieceList(page, this.state.pageSize, search, return PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
orderBy, this.state.orderAsc, filterBy);
}, },
fetchSelectedPieceEditionList() { fetchSelectedPieceEditionList() {
@ -250,8 +265,9 @@ let PieceList = React.createClass({
}, },
handleAclSuccess() { handleAclSuccess() {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderBy, orderAsc, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.fetchSelectedPieceEditionList() this.fetchSelectedPieceEditionList()
.forEach((pieceId) => { .forEach((pieceId) => {
@ -310,12 +326,12 @@ let PieceList = React.createClass({
className="text-center ascribe-button-list collapse-group"> className="text-center ascribe-button-list collapse-group">
<DeleteButton <DeleteButton
handleSuccess={this.handleAclSuccess} handleSuccess={this.handleAclSuccess}
editions={selectedEditions}/> editions={selectedEditions} />
</BulkModalButtonListType> </BulkModalButtonListType>
</PieceListBulkModal> </PieceListBulkModal>
<PieceListFilterDisplay <PieceListFilterDisplay
filterBy={this.state.filterBy} filterBy={this.state.filterBy}
filterParams={filterParams}/> filterParams={filterParams} />
<AccordionList <AccordionList
className="ascribe-accordion-list" className="ascribe-accordion-list"
changeOrder={this.accordionChangeOrder} changeOrder={this.accordionChangeOrder}

View File

@ -55,20 +55,15 @@ let RegisterPiece = React.createClass( {
this.setState(state); this.setState(state);
}, },
handleSuccess(response){ handleSuccess(response) {
const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
const notification = new GlobalNotificationModel(response.notification, 'success', 10000); const notification = new GlobalNotificationModel(response.notification, 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
// once the user was able to register a piece successfully, we need to make sure to keep // once the user was able to register a piece successfully, we need to make sure to keep
// the piece list up to date // the piece list up to date
PieceListActions.fetchPieceList( PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.page,
this.state.pageSize,
this.state.searchTerm,
this.state.orderBy,
this.state.orderAsc,
this.state.filterBy
);
this.history.push(`/pieces/${response.piece.id}`); this.history.push(`/pieces/${response.piece.id}`);
}, },
@ -84,7 +79,8 @@ let RegisterPiece = React.createClass( {
<input <input
type="number" type="number"
placeholder="(e.g. 32)" placeholder="(e.g. 32)"
min={0}/> min={1}
max={100} />
</Property> </Property>
); );
} }

View File

@ -3,19 +3,21 @@
import React from 'react'; import React from 'react';
import { History } from 'react-router'; 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 Form from '../../../../../ascribe_forms/form';
import Property from '../../../../../ascribe_forms/property'; import Property from '../../../../../ascribe_forms/property';
import InputTextAreaToggable from '../../../../../ascribe_forms/input_textarea_toggable'; 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 InputFineuploader from '../../../../../ascribe_forms/input_fineuploader';
import UploadButton from '../../../../../ascribe_uploader/ascribe_upload_button/upload_button';
import AscribeSpinner from '../../../../../ascribe_spinner'; 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 ApiUrls from '../../../../../../constants/api_urls';
import AppConstants from '../../../../../../constants/application_constants';
import { validationParts, validationTypes } from '../../../../../../constants/uploader_constants';
import requests from '../../../../../../utils/requests'; import requests from '../../../../../../utils/requests';
@ -192,11 +194,12 @@ const PRRegisterPieceForm = React.createClass({
render() { render() {
const { location } = this.props; const { location } = this.props;
const maxThumbnailSize = validationTypes.workThumbnail.sizeLimit / 1000000;
return ( return (
<div className="register-piece--form"> <div className="register-piece--form">
<Form <Form
buttons={{}} buttons={null}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref="registerPieceForm"> ref="registerPieceForm">
<Property <Property
@ -233,7 +236,7 @@ const PRRegisterPieceForm = React.createClass({
</Property> </Property>
</Form> </Form>
<Form <Form
buttons={{}} buttons={null}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref="additionalDataForm"> ref="additionalDataForm">
<Property <Property
@ -285,7 +288,7 @@ const PRRegisterPieceForm = React.createClass({
</Property> </Property>
</Form> </Form>
<Form <Form
buttons={{}} buttons={null}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref="uploadersForm"> ref="uploadersForm">
<Property <Property
@ -303,8 +306,8 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'digitalwork' fileClass: 'digitalwork'
}} }}
validation={{ validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit, itemLimit: validationTypes.registerWork.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit, sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: ['pdf'] allowedExtensions: ['pdf']
}} }}
location={location} location={location}
@ -316,7 +319,7 @@ const PRRegisterPieceForm = React.createClass({
</Property> </Property>
<Property <Property
name="thumbnailKey" name="thumbnailKey"
label={getLangText('Featured Cover photo (max 5MB)')}> label={`${getLangText('Featured Cover photo')} (max ${maxThumbnailSize}MB)`}>
<InputFineuploader <InputFineuploader
fileInputElement={UploadButton()} fileInputElement={UploadButton()}
createBlobRoutine={{ createBlobRoutine={{
@ -329,9 +332,9 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'thumbnail' fileClass: 'thumbnail'
}} }}
validation={{ validation={{
itemLimit: AppConstants.fineUploader.validation.workThumbnail.itemLimit, itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: AppConstants.fineUploader.validation.workThumbnail.sizeLimit, sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif'] allowedExtensions: validationParts.allowedExtensions.images
}} }}
location={location} location={location}
fileClassToUpload={{ fileClassToUpload={{
@ -354,8 +357,8 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'otherdata' fileClass: 'otherdata'
}} }}
validation={{ validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit, itemLimit: validationParts.itemLimit.single,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit sizeLimit: validationTypes.additionalData.sizeLimit
}} }}
location={location} location={location}
fileClassToUpload={{ fileClassToUpload={{
@ -376,9 +379,9 @@ const PRRegisterPieceForm = React.createClass({
fileClass: 'otherdata' fileClass: 'otherdata'
}} }}
validation={{ validation={{
itemLimit: AppConstants.fineUploader.validation.registerWork.itemLimit, itemLimit: validationParts.itemLimit.single,
sizeLimit: AppConstants.fineUploader.validation.additionalData.sizeLimit, sizeLimit: validationTypes.additionalData.sizeLimit,
allowedExtensions: ['png', 'jpg', 'jpeg', 'gif'] allowedExtensions: validationParts.allowedExtensions.images
}} }}
location={location} location={location}
fileClassToUpload={{ fileClassToUpload={{
@ -389,12 +392,11 @@ const PRRegisterPieceForm = React.createClass({
</Property> </Property>
</Form> </Form>
<Form <Form
buttons={{}} buttons={null}
className="ascribe-form-bordered"> className="ascribe-form-bordered">
<Property <Property
name="terms" name="terms"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<span> <span>
{getLangText('By submitting this form, you agree to the') + ' '} {getLangText('By submitting this form, you agree to the') + ' '}
<a <a

View File

@ -1,70 +1,47 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import EventActions from '../../../../actions/event_actions'; import EventActions from '../../../../actions/event_actions';
import UserStore from '../../../../stores/user_store'; import UserStore from '../../../../stores/user_store';
import UserActions from '../../../../actions/user_actions'; import UserActions from '../../../../actions/user_actions';
import WhitelabelActions from '../../../../actions/whitelabel_actions';
import WhitelabelStore from '../../../../stores/whitelabel_store';
import AppRouteWrapper from '../../../app_route_wrapper';
import Hero from './components/pr_hero'; import Hero from './components/pr_hero';
import Header from '../../../header';
import GlobalNotification from '../../../global_notification';
import { getSubdomain, mergeOptions } from '../../../../utils/general_utils'; import AppBase from '../../../app_base';
import AppRouteWrapper from '../../../app_route_wrapper';
import Footer from '../../../footer';
import Header from '../../../header';
import { getSubdomain } from '../../../../utils/general_utils';
import { getCookie } from '../../../../utils/fetch_api_utils'; import { getCookie } from '../../../../utils/fetch_api_utils';
let PRApp = React.createClass({ let PRApp = React.createClass({
propTypes: { propTypes: {
activeRoute: React.PropTypes.object.isRequired,
children: React.PropTypes.element.isRequired,
history: React.PropTypes.object.isRequired, history: React.PropTypes.object.isRequired,
routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
children: React.PropTypes.oneOfType([ // Provided from AppBase
React.PropTypes.arrayOf(React.PropTypes.element), currentUser: React.PropTypes.object,
React.PropTypes.element whitelabel: React.PropTypes.object
])
},
getInitialState() {
return mergeOptions(
UserStore.getState(),
WhitelabelStore.getState()
);
},
componentDidMount() {
UserStore.listen(this.onChange);
WhitelabelStore.listen(this.onChange);
UserActions.fetchCurrentUser();
WhitelabelActions.fetchWhitelabel();
},
componentWillUnmount() {
UserStore.unlisten(this.onChange);
WhitelabelActions.unlisten(this.onChange);
},
onChange(state) {
this.setState(state);
}, },
render() { render() {
const { children, history, routes } = this.props; const { activeRoute, children, currentUser, history, routes, whitelabel } = this.props;
const { currentUser, whitelabel } = this.state;
const subdomain = getSubdomain(); const subdomain = getSubdomain();
const path = activeRoute && activeRoute.path;
let style = {}; let style = {};
let header; let header;
if (currentUser && currentUser.email && history.isActive(`/pieces/${getCookie(currentUser.email)}`)) { if (currentUser && currentUser.email && history.isActive(`/pieces/${getCookie(currentUser.email)}`)) {
header = (<Hero currentUser={currentUser} />); header = (<Hero currentUser={currentUser} />);
style = { paddingTop: '0 !important' }; style = { paddingTop: '0 !important' };
} else if(currentUser && (currentUser.is_admin || currentUser.is_jury || currentUser.is_judge)) { } else if (currentUser && (currentUser.is_admin || currentUser.is_jury || currentUser.is_judge)) {
header = ( header = (
<Header <Header
currentUser={currentUser} currentUser={currentUser}
@ -77,23 +54,20 @@ let PRApp = React.createClass({
} }
return ( return (
<div> <div
style={style}
className={classNames('ascribe-prize-app', `route--${(path ? path.split('/')[0] : 'landing')}`)}>
{header} {header}
<div <AppRouteWrapper
style={style} currentUser={currentUser}
className={'container ascribe-prize-app client--' + subdomain}> whitelabel={whitelabel}>
<AppRouteWrapper {/* Routes are injected here */}
currentUser={currentUser} {children}
whitelabel={whitelabel}> </AppRouteWrapper>
{/* Routes are injected here */} <Footer activeRoute={activeRoute} />
{children}
</AppRouteWrapper>
<GlobalNotification />
<div id="modal" className="container"></div>
</div>
</div> </div>
); );
} }
}); });
export default PRApp; export default AppBase(PRApp);

View File

@ -31,74 +31,116 @@ import { AuthPrizeRoleRedirect } from './portfolioreview/components/pr_routes/pr
const ROUTES = { const ROUTES = {
sluice: ( sluice: (
<Route path='/' component={SPApp}> <Route path='/' component={SPApp}>
<IndexRoute component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPLanding)} /> <IndexRoute
component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPLanding)}
hideFooter />
<Route <Route
path='login' path='login'
component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPLoginContainer)} /> component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPLoginContainer)}
hideFooter />
<Route <Route
path='logout' path='logout'
component={ProxyHandler(AuthRedirect({to: '/', when: 'loggedOut'}))(LogoutContainer)}/> component={ProxyHandler(AuthRedirect({to: '/', when: 'loggedOut'}))(LogoutContainer)}
hideFooter />
<Route <Route
path='signup' path='signup'
component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPSignupContainer)} /> component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(SPSignupContainer)}
hideFooter />
<Route <Route
path='password_reset' path='password_reset'
component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(PasswordResetContainer)} /> component={ProxyHandler(AuthRedirect({to: '/collection', when: 'loggedIn'}))(PasswordResetContainer)}
hideFooter />
<Route <Route
path='settings' path='settings'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPSettingsContainer)}/> component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPSettingsContainer)}
hideFooter />
<Route <Route
path='register_piece' path='register_piece'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPRegisterPiece)} component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPRegisterPiece)}
headerTitle='+ NEW WORK'/> headerTitle='+ NEW WORK'
hideFooter />
<Route <Route
path='collection' path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPPieceList)} component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPPieceList)}
headerTitle='COLLECTION'/> headerTitle='COLLECTION'
<Route path='pieces/:pieceId' component={SluicePieceContainer} /> hideFooter />
<Route path='editions/:editionId' component={EditionContainer} /> <Route
<Route path='verify' component={CoaVerifyContainer} /> path='pieces/:pieceId'
<Route path='*' component={ErrorNotFoundPage} /> component={SluicePieceContainer}
hideFooter />
<Route
path='editions/:editionId'
component={EditionContainer}
hideFooter />
<Route
path='coa_verify'
component={CoaVerifyContainer}
hideFooter />
<Route
path='*'
component={ErrorNotFoundPage}
hideFooter />
</Route> </Route>
), ),
portfolioreview: ( portfolioreview: (
<Route path='/' component={PRApp}> <Route path='/' component={PRApp}>
<IndexRoute component={ProxyHandler(AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }))(PRLanding)} /> <IndexRoute
component={ProxyHandler(AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }))(PRLanding)}
hideFooter />
<Route <Route
path='register_piece' path='register_piece'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(PRRegisterPiece)}/> component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(PRRegisterPiece)}
hideFooter />
<Route <Route
path='collection' path='collection'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPPieceList)} component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPPieceList)}
headerTitle='SUBMISSIONS'/> headerTitle='SUBMISSIONS'
hideFooter />
<Route <Route
path='login' path='login'
component={ProxyHandler( component={ProxyHandler(
AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }), AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }),
AuthRedirect({to: '/register_piece', when: 'loggedIn'}) AuthRedirect({to: '/register_piece', when: 'loggedIn'})
)(SPLoginContainer)} /> )(SPLoginContainer)}
hideFooter />
<Route <Route
path='logout' path='logout'
component={ProxyHandler(AuthRedirect({to: '/', when: 'loggedOut'}))(LogoutContainer)} /> component={ProxyHandler(AuthRedirect({to: '/', when: 'loggedOut'}))(LogoutContainer)}
hideFooter />
<Route <Route
path='signup' path='signup'
component={ProxyHandler( component={ProxyHandler(
AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }), AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }),
AuthRedirect({to: '/register_piece', when: 'loggedIn'}) AuthRedirect({to: '/register_piece', when: 'loggedIn'})
)(SPSignupContainer)} /> )(SPSignupContainer)}
hideFooter />
<Route <Route
path='password_reset' path='password_reset'
component={ProxyHandler( component={ProxyHandler(
AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }), AuthPrizeRoleRedirect({ to: '/collection', when: ['is_admin', 'is_judge', 'is_jury'] }),
AuthRedirect({to: '/register_piece', when: 'loggedIn'}) AuthRedirect({to: '/register_piece', when: 'loggedIn'})
)(PasswordResetContainer)} /> )(PasswordResetContainer)}
hideFooter />
<Route <Route
path='settings' path='settings'
component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPSettingsContainer)}/> component={ProxyHandler(AuthRedirect({to: '/login', when: 'loggedOut'}))(SPSettingsContainer)}
<Route path='pieces/:pieceId' component={SPPieceContainer} /> hideFooter />
<Route path='editions/:editionId' component={EditionContainer} /> <Route
<Route path='verify' component={CoaVerifyContainer} /> path='pieces/:pieceId'
<Route path='*' component={ErrorNotFoundPage} /> component={SPPieceContainer}
hideFooter />
<Route
path='editions/:editionId'
component={EditionContainer}
hideFooter />
<Route
path='coa_verify'
component={CoaVerifyContainer}
hideFooter />
<Route
path='*'
component={ErrorNotFoundPage}
hideFooter />
</Route> </Route>
) )
}; };

View File

@ -52,8 +52,9 @@ let AccordionListItemPrize = React.createClass({
}, },
handleSubmitPrizeSuccess(response) { handleSubmitPrizeSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
const notification = new GlobalNotificationModel(response.notification, 'success', 10000); const notification = new GlobalNotificationModel(response.notification, 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
@ -133,8 +134,9 @@ let AccordionListItemPrize = React.createClass({
}, },
refreshPieceData() { refreshPieceData() {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
}, },
onSelectChange() { onSelectChange() {

View File

@ -14,10 +14,10 @@ import PrizeStore from '../../stores/prize_store';
import PrizeRatingActions from '../../actions/prize_rating_actions'; import PrizeRatingActions from '../../actions/prize_rating_actions';
import PrizeRatingStore from '../../stores/prize_rating_store'; import PrizeRatingStore from '../../stores/prize_rating_store';
import PieceActions from '../../../../../../actions/piece_actions';
import PieceStore from '../../../../../../stores/piece_store';
import PieceListStore from '../../../../../../stores/piece_list_store'; import PieceListStore from '../../../../../../stores/piece_list_store';
import PieceListActions from '../../../../../../actions/piece_list_actions'; import PieceListActions from '../../../../../../actions/piece_list_actions';
import PieceActions from '../../../../../../actions/piece_actions';
import PieceStore from '../../../../../../stores/piece_store';
import Piece from '../../../../../../components/ascribe_detail/piece'; import Piece from '../../../../../../components/ascribe_detail/piece';
import Note from '../../../../../../components/ascribe_detail/note'; import Note from '../../../../../../components/ascribe_detail/note';
@ -64,16 +64,7 @@ let PrizePieceContainer = React.createClass({
mixins: [ReactError], mixins: [ReactError],
getInitialState() { getInitialState() {
//FIXME: this component uses the PieceStore, but we avoid using the getState() here since it may contain stale data return PieceStore.getInitialState();
// It should instead use something like getInitialState() where that call also resets the state.
return {};
},
componentWillMount() {
// Every time we enter the piece detail page, just reset the piece
// store as it will otherwise display wrong/old data once the user loads
// the piece detail a second time
PieceActions.updatePiece({});
}, },
componentDidMount() { componentDidMount() {
@ -83,18 +74,19 @@ let PrizePieceContainer = React.createClass({
}, },
// This is done to update the container when the user clicks on the prev or next // This is done to update the container when the user clicks on the prev or next
// button to update the URL parameter (and therefore to switch pieces) // button to update the URL parameter (and therefore to switch pieces) or
// when the user clicks on a notification while being in another piece view
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.props.params.pieceId !== nextProps.params.pieceId) { if (this.props.params.pieceId !== nextProps.params.pieceId) {
PieceActions.updatePiece({}); PieceActions.flushPiece();
this.loadPiece(nextProps.params.pieceId); this.loadPiece(nextProps.params.pieceId);
} }
}, },
componentDidUpdate() { componentDidUpdate() {
const { pieceError } = this.state; const { pieceMeta: { err: pieceErr } } = this.state;
if (pieceError && pieceError.status === 404) { if (pieceErr && pieceErr.status === 404) {
this.throws(new ResourceNotFoundError(getLangText("Oops, the piece you're looking for doesn't exist."))); this.throws(new ResourceNotFoundError(getLangText("Oops, the piece you're looking for doesn't exist.")));
} }
}, },
@ -111,25 +103,25 @@ let PrizePieceContainer = React.createClass({
const { currentUser } = this.props; const { currentUser } = this.props;
const { piece } = this.state; const { piece } = this.state;
if (piece && piece.notifications && piece.notifications.length > 0) { if (piece.notifications && piece.notifications.length > 0) {
return ( return (
<ListRequestActions <ListRequestActions
pieceOrEditions={piece} pieceOrEditions={piece}
currentUser={currentUser} currentUser={currentUser}
handleSuccess={this.loadPiece} handleSuccess={this.loadPiece}
notifications={piece.notifications}/>); notifications={piece.notifications} />);
} }
}, },
loadPiece(pieceId = this.props.params.pieceId) { loadPiece(pieceId = this.props.params.pieceId) {
PieceActions.fetchOne(pieceId); PieceActions.fetchPiece(pieceId);
}, },
render() { render() {
const { currentUser, selectedPrizeActionButton } = this.props; const { currentUser, selectedPrizeActionButton } = this.props;
const { piece } = this.state; const { piece } = this.state;
if (piece && piece.id) { if (piece.id) {
/* /*
This really needs a refactor! This really needs a refactor!
@ -140,16 +132,19 @@ let PrizePieceContainer = React.createClass({
// Only show the artist name if you are the participant or if you are a judge and the piece is shortlisted // Only show the artist name if you are the participant or if you are a judge and the piece is shortlisted
let artistName; let artistName;
if ((currentUser.is_jury && !currentUser.is_judge) || (currentUser.is_judge && !piece.selected )) { if ((currentUser.is_jury && !currentUser.is_judge) || (currentUser.is_judge && !piece.selected )) {
artistName = <span className="glyphicon glyphicon-eye-close" aria-hidden="true"/>; artistName = <span className="glyphicon glyphicon-eye-close" aria-hidden="true" />;
setDocumentTitle(piece.title); setDocumentTitle(piece.title);
} else { } else {
artistName = piece.artist_name; artistName = piece.artist_name;
setDocumentTitle([artistName, piece.title].join(', ')); setDocumentTitle(`${artistName}, ${piece.title}`);
} }
// Only show the artist email if you are a judge and the piece is shortlisted // Only show the artist email if you are a judge and the piece is shortlisted
const artistEmail = (currentUser.is_judge && piece.selected ) ? const artistEmail = currentUser.is_judge && piece.selected ? (
<DetailProperty label={getLangText('REGISTREE')} value={ piece.user_registered } /> : null; <DetailProperty
label={getLangText('REGISTREE')}
value={piece.user_registered} />
) : null;
return ( return (
<Piece <Piece
@ -159,16 +154,16 @@ let PrizePieceContainer = React.createClass({
<div className="ascribe-detail-header"> <div className="ascribe-detail-header">
<NavigationHeader <NavigationHeader
piece={piece} piece={piece}
currentUser={currentUser}/> currentUser={currentUser} />
<h1 className="ascribe-detail-title">{piece.title}</h1> <h1 className="ascribe-detail-title">{piece.title}</h1>
<DetailProperty label={getLangText('BY')} value={artistName} /> <DetailProperty label={getLangText('BY')} value={artistName} />
<DetailProperty label={getLangText('DATE')} value={Moment(piece.date_created, 'YYYY-MM-DD').year()} /> <DetailProperty label={getLangText('DATE')} value={Moment(piece.date_created, 'YYYY-MM-DD').year()} />
{artistEmail} {artistEmail}
{this.getActions()} {this.getActions()}
<hr/> <hr />
</div> </div>
} }
subheader={ subheader={
<PrizePieceRatings <PrizePieceRatings
loadPiece={this.loadPiece} loadPiece={this.loadPiece}
@ -191,16 +186,15 @@ let PrizePieceContainer = React.createClass({
let NavigationHeader = React.createClass({ let NavigationHeader = React.createClass({
propTypes: { propTypes: {
piece: React.PropTypes.object, currentUser: React.PropTypes.object.isRequired,
currentUser: React.PropTypes.object piece: React.PropTypes.object.isRequired
}, },
render() { render() {
const { currentUser, piece } = this.props; const { currentUser, piece } = this.props;
if (currentUser && currentUser.email && currentUser.is_judge && currentUser.is_jury && if (currentUser.email && currentUser.is_judge && currentUser.is_jury && !currentUser.is_admin && piece.navigation) {
!currentUser.is_admin && piece && piece.navigation) { const nav = piece.navigation;
let nav = piece.navigation;
return ( return (
<div style={{marginBottom: '1em'}}> <div style={{marginBottom: '1em'}}>
@ -220,17 +214,19 @@ let NavigationHeader = React.createClass({
<hr/> <hr/>
</div> </div>
); );
} else {
return null;
} }
return null;
} }
}); });
let PrizePieceRatings = React.createClass({ let PrizePieceRatings = React.createClass({
propTypes: { propTypes: {
loadPiece: React.PropTypes.func, currentUser: React.PropTypes.object.isRequired,
piece: React.PropTypes.object, loadPiece: React.PropTypes.func.isRequired,
currentUser: React.PropTypes.object, piece: React.PropTypes.object.isRequired,
selectedPrizeActionButton: React.PropTypes.func selectedPrizeActionButton: React.PropTypes.func
}, },
@ -243,12 +239,18 @@ let PrizePieceRatings = React.createClass({
}, },
componentDidMount() { componentDidMount() {
PieceListStore.listen(this.onChange);
PrizeStore.listen(this.onChange);
PrizeRatingStore.listen(this.onChange); PrizeRatingStore.listen(this.onChange);
PrizeStore.listen(this.onChange);
PieceListStore.listen(this.onChange);
PrizeActions.fetchPrize(); PrizeActions.fetchPrize();
this.fetchPrizeRatings(); this.fetchRatingsIfAuthorized();
},
componentWillReceiveProps(nextProps) {
if (nextProps.currentUser.email !== this.props.currentUser.email) {
this.fetchRatingsIfAuthorized();
}
}, },
componentWillUnmount() { componentWillUnmount() {
@ -263,7 +265,7 @@ let PrizePieceRatings = React.createClass({
// with the problem. // with the problem.
onChange(state) { onChange(state) {
if (state.prize && state.prize.active_round != this.state.prize.active_round) { if (state.prize && state.prize.active_round != this.state.prize.active_round) {
this.fetchPrizeRatings(state); this.fetchRatingsIfAuthorized(state);
} }
this.setState(state); this.setState(state);
@ -278,9 +280,19 @@ let PrizePieceRatings = React.createClass({
} }
}, },
fetchPrizeRatings(state = this.state) { fetchRatingsIfAuthorized(state = this.state) {
PrizeRatingActions.fetchOne(this.props.piece.id, state.prize.active_round); const {
PrizeRatingActions.fetchAverage(this.props.piece.id, state.prize.active_round); currentUser: {
is_admin: isAdmin,
is_judge: isJudge,
is_jury: isJury
},
piece: { id: pieceId } } = this.props;
if (state.prize && 'active_round' in state.prize && (isAdmin || isJudge || isJury)) {
PrizeRatingActions.fetchOne(pieceId, state.prize.active_round);
PrizeRatingActions.fetchAverage(pieceId, state.prize.active_round);
}
}, },
onRatingClick(event, args) { onRatingClick(event, args) {
@ -305,9 +317,10 @@ let PrizePieceRatings = React.createClass({
}, },
refreshPieceData() { refreshPieceData() {
const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.props.loadPiece(); this.props.loadPiece();
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
}, },
onSelectChange() { onSelectChange() {
@ -324,7 +337,7 @@ let PrizePieceRatings = React.createClass({
}, },
render() { render() {
if (this.props.piece && this.props.currentUser && this.props.currentUser.is_judge && this.state.average) { if (this.props.piece.id && this.props.currentUser.is_judge && this.state.average) {
// Judge sees shortlisting, average and per-jury notes // Judge sees shortlisting, average and per-jury notes
return ( return (
<div> <div>
@ -356,7 +369,7 @@ let PrizePieceRatings = React.createClass({
size='md' size='md'
step={0.5} step={0.5}
rating={this.state.average} rating={this.state.average}
ratingAmount={5}/> ratingAmount={5} />
</div> </div>
<hr /> <hr />
{this.state.ratings.map((item, i) => { {this.state.ratings.map((item, i) => {
@ -381,7 +394,7 @@ let PrizePieceRatings = React.createClass({
size='sm' size='sm'
step={0.5} step={0.5}
rating={item.rating} rating={item.rating}
ratingAmount={5}/> ratingAmount={5} />
</span> </span>
<span> {item.user}</span> <span> {item.user}</span>
{note} {note}
@ -392,7 +405,7 @@ let PrizePieceRatings = React.createClass({
<hr /> <hr />
</CollapsibleParagraph> </CollapsibleParagraph>
</div>); </div>);
} else if (this.props.currentUser && this.props.currentUser.is_jury) { } else if (this.props.currentUser.is_jury) {
// Jury can set rating and note // Jury can set rating and note
return ( return (
<CollapsibleParagraph <CollapsibleParagraph
@ -410,14 +423,14 @@ let PrizePieceRatings = React.createClass({
ratingAmount={5} /> ratingAmount={5} />
</div> </div>
<Note <Note
id={() => {return {'piece_id': this.props.piece.id}; }} id={() => ({ 'piece_id': this.props.piece.id })}
label={getLangText('Jury note')} label={getLangText('Jury note')}
defaultValue={this.props.piece && this.props.piece.note_from_user ? this.props.piece.note_from_user.note : null} defaultValue={this.props.piece.note_from_user || null}
placeholder={getLangText('Enter your comments ...')} placeholder={getLangText('Enter your comments ...')}
editable={true} editable={true}
successMessage={getLangText('Jury note saved')} successMessage={getLangText('Jury note saved')}
url={ApiUrls.notes} url={ApiUrls.notes}
currentUser={this.props.currentUser}/> currentUser={this.props.currentUser} />
</CollapsibleParagraph>); </CollapsibleParagraph>);
} else { } else {
return null; return null;
@ -428,39 +441,40 @@ let PrizePieceRatings = React.createClass({
let PrizePieceDetails = React.createClass({ let PrizePieceDetails = React.createClass({
propTypes: { propTypes: {
piece: React.PropTypes.object piece: React.PropTypes.object.isRequired
}, },
render() { render() {
const { piece } = this.props; const { piece } = this.props;
if (piece && if (piece.prize && piece.prize.name && Object.keys(piece.extra_data).length) {
piece.prize &&
piece.prize.name &&
Object.keys(piece.extra_data).length !== 0) {
return ( return (
<CollapsibleParagraph <CollapsibleParagraph
title={getLangText('Prize Details')} title={getLangText('Prize Details')}
defaultExpanded={true}> defaultExpanded={true}>
<Form ref='form'> <Form>
{Object.keys(piece.extra_data).sort().map((data) => { {Object
// Remove leading number (for sorting), if any, and underscores with spaces .keys(piece.extra_data)
let label = data.replace(/^\d-/, '').replace(/_/g, ' '); .sort()
const value = piece.extra_data[data] || 'N/A'; .map((data) => {
// Remove leading number (for sorting), if any, and underscores with spaces
const label = data.replace(/^\d-/, '').replace(/_/g, ' ');
const value = piece.extra_data[data] || 'N/A';
return ( return (
<Property <Property
key={label} key={label}
name={data} name={data}
label={label} label={label}
editable={false} editable={false}
overrideForm={true}> overrideForm={true}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={value}/> defaultValue={value} />
</Property> </Property>
); );
})} })
}
<FurtherDetailsFileuploader <FurtherDetailsFileuploader
submitFile={() => {}} submitFile={() => {}}
setIsUploadReady={() => {}} setIsUploadReady={() => {}}
@ -473,8 +487,9 @@ let PrizePieceDetails = React.createClass({
</Form> </Form>
</CollapsibleParagraph> </CollapsibleParagraph>
); );
} else {
return null;
} }
return null;
} }
}); });

View File

@ -75,12 +75,11 @@ let PrizePieceList = React.createClass({
<PieceList <PieceList
ref="list" ref="list"
{...this.props} {...this.props}
redirectTo="/register_piece"
accordionListItemType={AccordionListItemPrize} accordionListItemType={AccordionListItemPrize}
orderParams={orderParams} customSubmitButton={this.getButtonSubmit()}
orderBy={currentUser.is_jury ? 'rating' : null}
filterParams={[]} filterParams={[]}
customSubmitButton={this.getButtonSubmit()} /> orderParams={orderParams}
orderBy={currentUser.is_jury ? 'rating' : null} />
); );
} }
}); });

View File

@ -75,8 +75,7 @@ let PrizeRegisterPiece = React.createClass({
</Property> </Property>
<Property <Property
name="terms" name="terms"
className="ascribe-property-collapsible-toggle" className="ascribe-property-collapsible-toggle">
style={{paddingBottom: 0}}>
<InputCheckbox> <InputCheckbox>
<span> <span>
{' ' + getLangText('I agree to the Terms of Service the art price') + ' '} {' ' + getLangText('I agree to the Terms of Service the art price') + ' '}

View File

@ -1,66 +1,34 @@
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import UserStore from '../../../../stores/user_store';
import UserActions from '../../../../actions/user_actions';
import WhitelabelActions from '../../../../actions/whitelabel_actions';
import WhitelabelStore from '../../../../stores/whitelabel_store';
import Hero from './components/prize_hero'; import Hero from './components/prize_hero';
import AppBase from '../../../app_base';
import AppRouteWrapper from '../../../app_route_wrapper'; import AppRouteWrapper from '../../../app_route_wrapper';
import Header from '../../../header';
import Footer from '../../../footer'; import Footer from '../../../footer';
import GlobalNotification from '../../../global_notification'; import Header from '../../../header';
import { getSubdomain, mergeOptions } from '../../../../utils/general_utils'; import { getSubdomain } from '../../../../utils/general_utils';
let PrizeApp = React.createClass({ let PrizeApp = React.createClass({
propTypes: { propTypes: {
activeRoute: React.PropTypes.object.isRequired,
children: React.PropTypes.element.isRequired,
history: React.PropTypes.object.isRequired, history: React.PropTypes.object.isRequired,
routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, routes: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
children: React.PropTypes.oneOfType([ // Provided from AppBase
React.PropTypes.arrayOf(React.PropTypes.element), currentUser: React.PropTypes.object,
React.PropTypes.element whitelabel: React.PropTypes.object
])
},
getInitialState() {
return mergeOptions(
UserStore.getState(),
WhitelabelStore.getState()
);
},
componentDidMount() {
UserStore.listen(this.onChange);
WhitelabelStore.listen(this.onChange);
UserActions.fetchCurrentUser();
WhitelabelActions.fetchWhitelabel();
},
componentWillUnmount() {
UserStore.unlisten(this.onChange);
WhitelabelActions.unlisten(this.onChange);
},
onChange(state) {
this.setState(state);
}, },
render() { render() {
const { children, history, routes } = this.props; const { activeRoute, children, currentUser, history, routes, whitelabel } = this.props;
const { currentUser, whitelabel } = this.state;
const subdomain = getSubdomain(); const subdomain = getSubdomain();
const path = activeRoute && activeRoute.path;
// The second element of routes is always the active component object, where we can
// extract the path.
let path = routes[1] ? routes[1].path : null;
let header = null; let header = null;
// if the path of the current activeRoute is not defined, then this is the IndexRoute // if the path of the current activeRoute is not defined, then this is the IndexRoute
@ -76,7 +44,7 @@ let PrizeApp = React.createClass({
} }
return ( return (
<div className={'container ascribe-prize-app client--' + subdomain}> <div className={classNames('ascribe-prize-app', `route--${(path ? path.split('/')[0] : 'landing')}`)}>
{header} {header}
<AppRouteWrapper <AppRouteWrapper
currentUser={currentUser} currentUser={currentUser}
@ -84,12 +52,10 @@ let PrizeApp = React.createClass({
{/* Routes are injected here */} {/* Routes are injected here */}
{children} {children}
</AppRouteWrapper> </AppRouteWrapper>
<Footer /> <Footer activeRoute={activeRoute} />
<GlobalNotification />
<div id="modal" className="container"></div>
</div> </div>
); );
} }
}); });
export default PrizeApp; export default AppBase(PrizeApp);

View File

@ -14,6 +14,18 @@ class PrizeRatingStore {
}); });
} }
getInitialState() {
this.ratings = [];
this.currentRating = null;
this.average = null;
return {
ratings: this.ratings,
currentRating: this.currentRating,
average: this.average
};
}
onUpdatePrizeRatings(ratings) { onUpdatePrizeRatings(ratings) {
this.ratings = ratings; this.ratings = ratings;
} }
@ -30,18 +42,6 @@ class PrizeRatingStore {
onResetPrizeRatings() { onResetPrizeRatings() {
this.getInitialState(); this.getInitialState();
} }
getInitialState() {
this.ratings = [];
this.currentRating = null;
this.average = null;
return {
ratings: this.ratings,
currentRating: this.currentRating,
average: this.average
};
}
} }
export default alt.createStore(PrizeRatingStore, 'PrizeRatingStore'); export default alt.createStore(PrizeRatingStore, 'PrizeRatingStore');

View File

@ -1,4 +1,4 @@
'use strict' 'use strict';
import React from 'react'; import React from 'react';
import Moment from 'moment'; import Moment from 'moment';

View File

@ -1,4 +1,4 @@
'use strict' 'use strict';
import React from 'react'; import React from 'react';

View File

@ -26,7 +26,7 @@ let Vivi23Landing = React.createClass({
render() { render() {
return ( return (
<div className="container ascribe-form-wrapper vivi23-landing"> <div className="ascribe-form-wrapper vivi23-landing">
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12">
<div className="row vivi23-landing--header"> <div className="row vivi23-landing--header">

View File

@ -1,4 +1,4 @@
'use strict' 'use strict';
import React from 'react'; import React from 'react';

View File

@ -46,8 +46,9 @@ let CylandAccordionListItem = React.createClass({
}, },
handleSubmitSuccess(response) { handleSubmitSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
const notification = new GlobalNotificationModel(response.notification, 'success', 10000); const notification = new GlobalNotificationModel(response.notification, 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);

View File

@ -44,7 +44,7 @@ let CylandPieceContainer = React.createClass({
getInitialState() { getInitialState() {
return mergeOptions( return mergeOptions(
PieceStore.getState(), PieceStore.getInitialState(),
PieceListStore.getState() PieceListStore.getState()
); );
}, },
@ -53,14 +53,17 @@ let CylandPieceContainer = React.createClass({
PieceStore.listen(this.onChange); PieceStore.listen(this.onChange);
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
// Every time we enter the piece detail page, just reset the piece
// store as it will otherwise display wrong/old data once the user loads
// the piece detail a second time
PieceActions.updatePiece({});
this.loadPiece(); this.loadPiece();
}, },
// We need this for when the user clicks on a notification while being in another piece view
componentWillReceiveProps(nextProps) {
if (this.props.params.pieceId !== nextProps.params.pieceId) {
PieceActions.flushPiece();
this.loadPiece();
}
},
componentWillUnmount() { componentWillUnmount() {
PieceStore.unlisten(this.onChange); PieceStore.unlisten(this.onChange);
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
@ -71,12 +74,13 @@ let CylandPieceContainer = React.createClass({
}, },
loadPiece() { loadPiece() {
PieceActions.fetchOne(this.props.params.pieceId); PieceActions.fetchPiece(this.props.params.pieceId);
}, },
handleDeleteSuccess(response) { handleDeleteSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
// since we're deleting a piece, we just need to close // since we're deleting a piece, we just need to close
// all editions dialogs and not reload them // all editions dialogs and not reload them
@ -90,10 +94,12 @@ let CylandPieceContainer = React.createClass({
}, },
render() { render() {
if (this.state.piece && this.state.piece.id) { const { piece } = this.state;
if (piece.id) {
const { currentUser } = this.props; const { currentUser } = this.props;
setDocumentTitle([this.state.piece.artist_name, this.state.piece.title].join(', ')); setDocumentTitle(`${piece.artist_name}, ${piece.title}`);
return ( return (
<WalletPieceContainer <WalletPieceContainer
@ -107,14 +113,13 @@ let CylandPieceContainer = React.createClass({
title={getLangText('Further Details')} title={getLangText('Further Details')}
defaultExpanded={true}> defaultExpanded={true}>
<CylandAdditionalDataForm <CylandAdditionalDataForm
piece={this.state.piece} piece={piece}
disabled={!this.state.piece.acl.acl_edit} disabled={!piece.acl.acl_edit}
isInline={true} /> isInline={true} />
</CollapsibleParagraph> </CollapsibleParagraph>
</WalletPieceContainer> </WalletPieceContainer>
); );
} } else {
else {
return ( return (
<div className="fullpage-spinner"> <div className="fullpage-spinner">
<AscribeSpinner color='dark-blue' size='lg' /> <AscribeSpinner color='dark-blue' size='lg' />

View File

@ -23,9 +23,10 @@ import { formSubmissionValidation } from '../../../../../ascribe_uploader/react_
let CylandAdditionalDataForm = React.createClass({ let CylandAdditionalDataForm = React.createClass({
propTypes: { propTypes: {
handleSuccess: React.PropTypes.func,
piece: React.PropTypes.object.isRequired, piece: React.PropTypes.object.isRequired,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
handleSuccess: React.PropTypes.func,
isInline: React.PropTypes.bool isInline: React.PropTypes.bool
}, },
@ -42,13 +43,13 @@ let CylandAdditionalDataForm = React.createClass({
}, },
handleSuccess() { handleSuccess() {
let notification = new GlobalNotificationModel(getLangText('Further details successfully updated'), 'success', 10000); const notification = new GlobalNotificationModel(getLangText('Further details successfully updated'), 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
getFormData() { getFormData() {
let extradata = {}; const extradata = {};
let formRefs = this.refs.form.refs; const formRefs = this.refs.form.refs;
// Put additional fields in extra data object // Put additional fields in extra data object
Object Object
@ -71,10 +72,13 @@ let CylandAdditionalDataForm = React.createClass({
}, },
render() { render() {
let { piece, isInline, disabled, handleSuccess, location } = this.props; const { disabled, handleSuccess, isInline, piece } = this.props;
let buttons, spinner, heading;
if(!isInline) { let buttons;
let spinner;
let heading;
if (!isInline) {
buttons = ( buttons = (
<button <button
type="submit" type="submit"
@ -87,7 +91,7 @@ let CylandAdditionalDataForm = React.createClass({
spinner = ( spinner = (
<div className="modal-footer"> <div className="modal-footer">
<p className="pull-right"> <p className="pull-right">
<AscribeSpinner color='dark-blue' size='md'/> <AscribeSpinner color='dark-blue' size='md' />
</p> </p>
</div> </div>
); );
@ -101,13 +105,15 @@ let CylandAdditionalDataForm = React.createClass({
); );
} }
if(piece && piece.id) { if (piece.id) {
const { extra_data: extraData = {} } = piece;
return ( return (
<Form <Form
disabled={disabled} disabled={disabled}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref='form' ref='form'
url={requests.prepareUrl(ApiUrls.piece_extradata, {piece_id: piece.id})} url={requests.prepareUrl(ApiUrls.piece_extradata, { piece_id: piece.id })}
handleSuccess={handleSuccess || this.handleSuccess} handleSuccess={handleSuccess || this.handleSuccess}
getFormData={this.getFormData} getFormData={this.getFormData}
buttons={buttons} buttons={buttons}
@ -116,65 +122,66 @@ let CylandAdditionalDataForm = React.createClass({
<Property <Property
name='artist_bio' name='artist_bio'
label={getLangText('Artist Biography')} label={getLangText('Artist Biography')}
expanded={!disabled || !!piece.extra_data.artist_bio}> expanded={!disabled || !!extraData.artist_bio}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.artist_bio} defaultValue={extraData.artist_bio}
placeholder={getLangText('Enter the artist\'s biography...')}/> placeholder={getLangText('Enter the artist\'s biography...')} />
</Property> </Property>
<Property <Property
name='artist_contact_information' name='artist_contact_information'
label={getLangText('Artist Contact Information')} label={getLangText('Artist Contact Information')}
expanded={!disabled || !!piece.extra_data.artist_contact_information}> expanded={!disabled || !!extraData.artist_contact_information}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.artist_contact_information} convertLinks
placeholder={getLangText('Enter the artist\'s contact information...')}/> defaultValue={extraData.artist_contact_information}
placeholder={getLangText('Enter the artist\'s contact information...')} />
</Property> </Property>
<Property <Property
name='conceptual_overview' name='conceptual_overview'
label={getLangText('Conceptual Overview')} label={getLangText('Conceptual Overview')}
expanded={!disabled || !!piece.extra_data.conceptual_overview}> expanded={!disabled || !!extraData.conceptual_overview}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.conceptual_overview} defaultValue={extraData.conceptual_overview}
placeholder={getLangText('Enter a conceptual overview...')}/> placeholder={getLangText('Enter a conceptual overview...')} />
</Property> </Property>
<Property <Property
name='medium' name='medium'
label={getLangText('Medium (technical specifications)')} label={getLangText('Medium (technical specifications)')}
expanded={!disabled || !!piece.extra_data.medium}> expanded={!disabled || !!extraData.medium}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.medium} defaultValue={extraData.medium}
placeholder={getLangText('Enter the medium (and other technical specifications)...')}/> placeholder={getLangText('Enter the medium (and other technical specifications)...')} />
</Property> </Property>
<Property <Property
name='size_duration' name='size_duration'
label={getLangText('Size / Duration')} label={getLangText('Size / Duration')}
expanded={!disabled || !!piece.extra_data.size_duration}> expanded={!disabled || !!extraData.size_duration}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.size_duration} defaultValue={extraData.size_duration}
placeholder={getLangText('Enter the size / duration...')}/> placeholder={getLangText('Enter the size / duration...')} />
</Property> </Property>
<Property <Property
name='display_instructions' name='display_instructions'
label={getLangText('Display instructions')} label={getLangText('Display instructions')}
expanded={!disabled || !!piece.extra_data.display_instructions}> expanded={!disabled || !!extraData.display_instructions}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.display_instructions} defaultValue={extraData.display_instructions}
placeholder={getLangText('Enter the display instructions...')}/> placeholder={getLangText('Enter the display instructions...')} />
</Property> </Property>
<Property <Property
name='additional_details' name='additional_details'
label={getLangText('Additional details')} label={getLangText('Additional details')}
expanded={!disabled || !!piece.extra_data.additional_details}> expanded={!disabled || !!extraData.additional_details}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.additional_details} defaultValue={extraData.additional_details}
placeholder={getLangText('Enter additional details...')}/> placeholder={getLangText('Enter additional details...')} />
</Property> </Property>
<FurtherDetailsFileuploader <FurtherDetailsFileuploader
label={getLangText('Additional files (e.g. still images, pdf)')} label={getLangText('Additional files (e.g. still images, pdf)')}
@ -189,7 +196,7 @@ let CylandAdditionalDataForm = React.createClass({
} else { } else {
return ( return (
<div className="ascribe-loading-position"> <div className="ascribe-loading-position">
<AscribeSpinner color='dark-blue' size='md'/> <AscribeSpinner color='dark-blue' size='md' />
</div> </div>
); );
} }

View File

@ -26,7 +26,7 @@ let CylandLanding = React.createClass({
setDocumentTitle('CYLAND MediaArtLab'); setDocumentTitle('CYLAND MediaArtLab');
return ( return (
<div className="container ascribe-form-wrapper cyland-landing"> <div className="ascribe-form-wrapper cyland-landing">
<div className="row"> <div className="row">
<div className="col-xs-12"> <div className="col-xs-12">
<div className="row" style={{border: '1px solid #CCC', padding: '2em'}}> <div className="row" style={{border: '1px solid #CCC', padding: '2em'}}>

View File

@ -8,7 +8,6 @@ import CylandAccordionListItem from './cyland_accordion_list/cyland_accordion_li
import { getLangText } from '../../../../../utils/lang_utils'; import { getLangText } from '../../../../../utils/lang_utils';
import { setDocumentTitle } from '../../../../../utils/dom_utils'; import { setDocumentTitle } from '../../../../../utils/dom_utils';
let CylandPieceList = React.createClass({ let CylandPieceList = React.createClass({
propTypes: { propTypes: {
// Provided from PrizeApp // Provided from PrizeApp
@ -19,23 +18,36 @@ let CylandPieceList = React.createClass({
location: React.PropTypes.object location: React.PropTypes.object
}, },
shouldRedirect(pieceCount) {
const { currentUser: { email: userEmail },
whitelabel: {
user: whitelabelAdminEmail
} } = this.props;
return userEmail !== whitelabelAdminEmail && !pieceCount;
},
render() { render() {
setDocumentTitle(getLangText('Collection')); setDocumentTitle(getLangText('Collection'));
return ( return (
<div> <PieceList
<PieceList {...this.props}
{...this.props} accordionListItemType={CylandAccordionListItem}
redirectTo="/register_piece?slide_num=0" filterParams={[{
accordionListItemType={CylandAccordionListItem} label: getLangText('Show works I have'),
filterParams={[{ items: [{
label: getLangText('Show works I have'), key: 'acl_loaned',
items: [{ label: getLangText('loaned to Cyland')
key: 'acl_loaned', }]
label: getLangText('loaned to Cyland') }]}
}] redirectTo={{
}]} /> pathname: '/register_piece',
</div> query: {
'slide_num': 0
}
}}
shouldRedirect={this.shouldRedirect} />
); );
} }
}); });

View File

@ -49,7 +49,7 @@ let CylandRegisterPiece = React.createClass({
getInitialState(){ getInitialState(){
return mergeOptions( return mergeOptions(
PieceListStore.getState(), PieceListStore.getState(),
PieceStore.getState(), PieceStore.getInitialState(),
{ {
step: 0 step: 0
}); });
@ -68,8 +68,8 @@ let CylandRegisterPiece = React.createClass({
// //
// We're using 'in' here as we want to know if 'piece_id' is present in the url, // We're using 'in' here as we want to know if 'piece_id' is present in the url,
// we don't care about the value. // we don't care about the value.
if (queryParams && 'piece_id' in queryParams) { if ('piece_id' in queryParams) {
PieceActions.fetchOne(queryParams.piece_id); PieceActions.fetchPiece(queryParams.piece_id);
} }
}, },
@ -82,31 +82,26 @@ let CylandRegisterPiece = React.createClass({
this.setState(state); this.setState(state);
}, },
handleRegisterSuccess(response){ handleRegisterSuccess(response) {
this.refreshPieceList(); this.refreshPieceList();
// also start loading the piece for the next step // Also load the newly registered piece for the next step
if (response && response.piece) { if (response && response.piece) {
PieceActions.updatePiece({});
PieceActions.updatePiece(response.piece); PieceActions.updatePiece(response.piece);
} }
this.incrementStep(); this.nextSlide({ piece_id: response.piece.id });
this.refs.slidesContainer.nextSlide({ piece_id: response.piece.id });
}, },
handleAdditionalDataSuccess() { handleAdditionalDataSuccess() {
// We need to refetch the piece again after submitting the additional data // We need to refetch the piece again after submitting the additional data
// since we want it's otherData to be displayed when the user choses to click // since we want its otherData to be displayed when the user choses to click
// on the browsers back button. // on the browsers back button.
PieceActions.fetchOne(this.state.piece.id); PieceActions.fetchPiece(this.state.piece.id);
this.refreshPieceList(); this.refreshPieceList();
this.incrementStep(); this.nextSlide();
this.refs.slidesContainer.nextSlide();
}, },
handleLoanSuccess(response) { handleLoanSuccess(response) {
@ -115,29 +110,22 @@ let CylandRegisterPiece = React.createClass({
this.refreshPieceList(); this.refreshPieceList();
PieceActions.fetchOne(this.state.piece.id);
this.history.push(`/pieces/${this.state.piece.id}`); this.history.push(`/pieces/${this.state.piece.id}`);
}, },
// We need to increase the step to lock the forms that are already filled out nextSlide(queryParams) {
incrementStep() { // We need to increase the step to lock the forms that are already filled out
// also increase step
let newStep = this.state.step + 1;
this.setState({ this.setState({
step: newStep step: this.state.step + 1
}); });
this.refs.slidesContainer.nextSlide(queryParams);
}, },
refreshPieceList() { refreshPieceList() {
PieceListActions.fetchPieceList( const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.page,
this.state.pageSize, PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.searchTerm,
this.state.orderBy,
this.state.orderAsc,
this.state.filterBy
);
}, },
render() { render() {
@ -145,8 +133,7 @@ let CylandRegisterPiece = React.createClass({
const { piece, step } = this.state; const { piece, step } = this.state;
const today = new Moment(); const today = new Moment();
const datetimeWhenWeAllWillBeFlyingCoolHoverboardsAndDinosaursWillLiveAgain = new Moment(); const datetimeWhenWeAllWillBeFlyingCoolHoverboardsAndDinosaursWillLiveAgain = new Moment().add(1000, 'years');
datetimeWhenWeAllWillBeFlyingCoolHoverboardsAndDinosaursWillLiveAgain.add(1000, 'years');
const loanHeading = getLangText('Loan to Cyland archive'); const loanHeading = getLangText('Loan to Cyland archive');
const loanButtons = ( const loanButtons = (

View File

@ -48,8 +48,9 @@ let IkonotvAccordionListItem = React.createClass({
}, },
handleSubmitSuccess(response) { handleSubmitSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
const notification = new GlobalNotificationModel(response.notification, 'success', 10000); const notification = new GlobalNotificationModel(response.notification, 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);

View File

@ -75,7 +75,7 @@ let IkonotvContractNotifications = React.createClass({
<a href={blob.url_safe} target="_blank"> <a href={blob.url_safe} target="_blank">
<Glyphicon glyph='download-alt'/> <Glyphicon glyph='download-alt'/>
<span style={{padding: '0.3em'}}> <span style={{padding: '0.3em'}}>
Download contract {getLangText('Download contract')}
</span> </span>
</a> </a>
</div> </div>

View File

@ -8,12 +8,12 @@ import EditionListActions from '../../../../../../actions/edition_list_actions';
import GlobalNotificationModel from '../../../../../../models/global_notification_model'; import GlobalNotificationModel from '../../../../../../models/global_notification_model';
import GlobalNotificationActions from '../../../../../../actions/global_notification_actions'; import GlobalNotificationActions from '../../../../../../actions/global_notification_actions';
import PieceActions from '../../../../../../actions/piece_actions';
import PieceStore from '../../../../../../stores/piece_store';
import PieceListStore from '../../../../../../stores/piece_list_store'; import PieceListStore from '../../../../../../stores/piece_list_store';
import PieceListActions from '../../../../../../actions/piece_list_actions'; import PieceListActions from '../../../../../../actions/piece_list_actions';
import PieceActions from '../../../../../../actions/piece_actions';
import PieceStore from '../../../../../../stores/piece_store';
import IkonotvSubmitButton from '../ikonotv_buttons/ikonotv_submit_button'; import IkonotvSubmitButton from '../ikonotv_buttons/ikonotv_submit_button';
import IkonotvArtistDetailsForm from '../ikonotv_forms/ikonotv_artist_details_form'; import IkonotvArtistDetailsForm from '../ikonotv_forms/ikonotv_artist_details_form';
@ -45,8 +45,8 @@ let IkonotvPieceContainer = React.createClass({
getInitialState() { getInitialState() {
return mergeOptions( return mergeOptions(
PieceStore.getState(), PieceListStore.getState(),
PieceListStore.getState() PieceStore.getInitialState()
); );
}, },
@ -54,19 +54,14 @@ let IkonotvPieceContainer = React.createClass({
PieceStore.listen(this.onChange); PieceStore.listen(this.onChange);
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
// Every time we enter the piece detail page, just reset the piece
// store as it will otherwise display wrong/old data once the user loads
// the piece detail a second time
PieceActions.updatePiece({});
this.loadPiece(); this.loadPiece();
}, },
// We need this for when the user clicks on a notification while being in another piece view // We need this for when the user clicks on a notification while being in another piece view
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (this.props.params.pieceId !== nextProps.params.pieceId) { if (this.props.params.pieceId !== nextProps.params.pieceId) {
PieceActions.updatePiece({}); PieceActions.flushPiece();
PieceActions.fetchOne(nextProps.params.pieceId); this.loadPiece();
} }
}, },
@ -80,12 +75,13 @@ let IkonotvPieceContainer = React.createClass({
}, },
loadPiece() { loadPiece() {
PieceActions.fetchOne(this.props.params.pieceId); PieceActions.fetchPiece(this.props.params.pieceId);
}, },
handleDeleteSuccess(response) { handleDeleteSuccess(response) {
PieceListActions.fetchPieceList(this.state.page, this.state.pageSize, this.state.search, const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.orderBy, this.state.orderAsc, this.state.filterBy);
PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
// since we're deleting a piece, we just need to close // since we're deleting a piece, we just need to close
// all editions dialogs and not reload them // all editions dialogs and not reload them
@ -110,7 +106,7 @@ let IkonotvPieceContainer = React.createClass({
</CollapsibleParagraph> </CollapsibleParagraph>
); );
if (piece.extra_data && Object.keys(piece.extra_data).length > 0 && piece.acl) { if (piece.extra_data && Object.keys(piece.extra_data).length && piece.acl) {
furtherDetails = ( furtherDetails = (
<CollapsibleParagraph <CollapsibleParagraph
title={getLangText('Further Details')} title={getLangText('Further Details')}
@ -127,8 +123,8 @@ let IkonotvPieceContainer = React.createClass({
); );
} }
if (piece && piece.id) { if (piece.id) {
setDocumentTitle([piece.artist_name, piece.title].join(', ')); setDocumentTitle(`${piece.artist_name}, ${piece.title}`);
return ( return (
<WalletPieceContainer <WalletPieceContainer

View File

@ -20,11 +20,10 @@ import { getLangText } from '../../../../../../utils/lang_utils';
let IkonotvArtistDetailsForm = React.createClass({ let IkonotvArtistDetailsForm = React.createClass({
propTypes: { propTypes: {
handleSuccess: React.PropTypes.func,
piece: React.PropTypes.object.isRequired, piece: React.PropTypes.object.isRequired,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
handleSuccess: React.PropTypes.func,
isInline: React.PropTypes.bool isInline: React.PropTypes.bool
}, },
@ -35,8 +34,8 @@ let IkonotvArtistDetailsForm = React.createClass({
}, },
getFormData() { getFormData() {
let extradata = {}; const extradata = {};
let formRefs = this.refs.form.refs; const formRefs = this.refs.form.refs;
// Put additional fields in extra data object // Put additional fields in extra data object
Object Object
@ -53,20 +52,23 @@ let IkonotvArtistDetailsForm = React.createClass({
}, },
handleSuccess() { handleSuccess() {
let notification = new GlobalNotificationModel('Artist details successfully updated', 'success', 10000); const notification = new GlobalNotificationModel(getLangText('Artist details successfully updated'), 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
render() { render() {
let buttons, spinner, heading; const { disabled, isInline, handleSuccess, piece } = this.props;
let { isInline, handleSuccess } = this.props;
let buttons;
let spinner;
let heading;
if (!isInline) { if (!isInline) {
buttons = ( buttons = (
<button <button
type="submit" type="submit"
className="btn btn-default btn-wide" className="btn btn-default btn-wide"
disabled={this.props.disabled}> disabled={disabled}>
{getLangText('Proceed to loan')} {getLangText('Proceed to loan')}
</button> </button>
); );
@ -74,7 +76,7 @@ let IkonotvArtistDetailsForm = React.createClass({
spinner = ( spinner = (
<div className="modal-footer"> <div className="modal-footer">
<p className="pull-right"> <p className="pull-right">
<AscribeSpinner color='dark-blue' size='md'/> <AscribeSpinner color='dark-blue' size='md' />
</p> </p>
</div> </div>
); );
@ -88,13 +90,15 @@ let IkonotvArtistDetailsForm = React.createClass({
); );
} }
if (this.props.piece && this.props.piece.id && this.props.piece.extra_data) { if (piece.id) {
const { extra_data: extraData = {} } = piece;
return ( return (
<Form <Form
disabled={this.props.disabled} disabled={disabled}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref='form' ref='form'
url={requests.prepareUrl(ApiUrls.piece_extradata, {piece_id: this.props.piece.id})} url={requests.prepareUrl(ApiUrls.piece_extradata, { piece_id: piece.id })}
handleSuccess={handleSuccess || this.handleSuccess} handleSuccess={handleSuccess || this.handleSuccess}
getFormData={this.getFormData} getFormData={this.getFormData}
buttons={buttons} buttons={buttons}
@ -103,39 +107,41 @@ let IkonotvArtistDetailsForm = React.createClass({
<Property <Property
name='artist_website' name='artist_website'
label={getLangText('Artist Website')} label={getLangText('Artist Website')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.artist_website}> expanded={!disabled || !!extraData.artist_website}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.artist_website} convertLinks
placeholder={getLangText('The artist\'s website if present...')}/> defaultValue={extraData.artist_website}
placeholder={getLangText('The artist\'s website if present...')} />
</Property> </Property>
<Property <Property
name='gallery_website' name='gallery_website'
label={getLangText('Website of related Gallery, Museum, etc.')} label={getLangText('Website of related Gallery, Museum, etc.')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.gallery_website}> expanded={!disabled || !!extraData.gallery_website}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.gallery_website} convertLinks
placeholder={getLangText('The website of any related Gallery or Museum')}/> defaultValue={extraData.gallery_website}
placeholder={getLangText('The website of any related Gallery or Museum')} />
</Property> </Property>
<Property <Property
name='additional_websites' name='additional_websites'
label={getLangText('Additional Websites/Publications/Museums/Galleries')} label={getLangText('Additional Websites/Publications/Museums/Galleries')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.additional_websites}> expanded={!disabled || !!extraData.additional_websites}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.additional_websites} convertLinks
placeholder={getLangText('Enter additional Websites/Publications if any')}/> defaultValue={extraData.additional_websites}
placeholder={getLangText('Enter additional Websites/Publications if any')} />
</Property> </Property>
<Property <Property
name='conceptual_overview' name='conceptual_overview'
label={getLangText('Short text about the Artist')} label={getLangText('Short text about the Artist')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.conceptual_overview}> expanded={!disabled || !!extraData.conceptual_overview}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.conceptual_overview} defaultValue={extraData.conceptual_overview}
placeholder={getLangText('Enter a short bio about the Artist')} placeholder={getLangText('Enter a short bio about the Artist')} />
/>
</Property> </Property>
</Form> </Form>
); );

View File

@ -20,11 +20,10 @@ import { getLangText } from '../../../../../../utils/lang_utils';
let IkonotvArtworkDetailsForm = React.createClass({ let IkonotvArtworkDetailsForm = React.createClass({
propTypes: { propTypes: {
handleSuccess: React.PropTypes.func,
piece: React.PropTypes.object.isRequired, piece: React.PropTypes.object.isRequired,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
handleSuccess: React.PropTypes.func,
isInline: React.PropTypes.bool isInline: React.PropTypes.bool
}, },
@ -35,8 +34,8 @@ let IkonotvArtworkDetailsForm = React.createClass({
}, },
getFormData() { getFormData() {
let extradata = {}; const extradata = {};
let formRefs = this.refs.form.refs; const formRefs = this.refs.form.refs;
// Put additional fields in extra data object // Put additional fields in extra data object
Object Object
@ -53,20 +52,23 @@ let IkonotvArtworkDetailsForm = React.createClass({
}, },
handleSuccess() { handleSuccess() {
let notification = new GlobalNotificationModel('Artwork details successfully updated', 'success', 10000); const notification = new GlobalNotificationModel(getLangText('Artwork details successfully updated'), 'success', 10000);
GlobalNotificationActions.appendGlobalNotification(notification); GlobalNotificationActions.appendGlobalNotification(notification);
}, },
render() { render() {
let buttons, spinner, heading; const { disabled, isInline, handleSuccess, piece } = this.props;
let { isInline, handleSuccess } = this.props;
let buttons;
let spinner;
let heading;
if (!isInline) { if (!isInline) {
buttons = ( buttons = (
<button <button
type="submit" type="submit"
className="btn btn-default btn-wide" className="btn btn-default btn-wide"
disabled={this.props.disabled}> disabled={disabled}>
{getLangText('Proceed to artist details')} {getLangText('Proceed to artist details')}
</button> </button>
); );
@ -74,7 +76,7 @@ let IkonotvArtworkDetailsForm = React.createClass({
spinner = ( spinner = (
<div className="modal-footer"> <div className="modal-footer">
<p className="pull-right"> <p className="pull-right">
<AscribeSpinner color='dark-blue' size='md'/> <AscribeSpinner color='dark-blue' size='md' />
</p> </p>
</div> </div>
); );
@ -88,13 +90,15 @@ let IkonotvArtworkDetailsForm = React.createClass({
); );
} }
if (this.props.piece && this.props.piece.id && this.props.piece.extra_data) { if (piece.id && piece.extra_data) {
const { extra_data: extraData = {} } = piece;
return ( return (
<Form <Form
disabled={this.props.disabled} disabled={disabled}
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref='form' ref='form'
url={requests.prepareUrl(ApiUrls.piece_extradata, {piece_id: this.props.piece.id})} url={requests.prepareUrl(ApiUrls.piece_extradata, { piece_id: piece.id })}
handleSuccess={handleSuccess || this.handleSuccess} handleSuccess={handleSuccess || this.handleSuccess}
getFormData={this.getFormData} getFormData={this.getFormData}
buttons={buttons} buttons={buttons}
@ -103,56 +107,56 @@ let IkonotvArtworkDetailsForm = React.createClass({
<Property <Property
name='medium' name='medium'
label={getLangText('Medium')} label={getLangText('Medium')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.medium}> expanded={!disabled || !!extraData.medium}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.medium} defaultValue={extraData.medium}
placeholder={getLangText('The medium of the file (i.e. photo, video, other, ...)')}/> placeholder={getLangText('The medium of the file (i.e. photo, video, other, ...)')} />
</Property> </Property>
<Property <Property
name='size_duration' name='size_duration'
label={getLangText('Size/Duration')} label={getLangText('Size/Duration')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.size_duration}> expanded={!disabled || !!extraData.size_duration}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.size_duration} defaultValue={extraData.size_duration}
placeholder={getLangText('Size in centimeters. Duration in minutes.')}/> placeholder={getLangText('Size in centimeters. Duration in minutes.')} />
</Property> </Property>
<Property <Property
name='copyright' name='copyright'
label={getLangText('Copyright')} label={getLangText('Copyright')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.copyright}> expanded={!disabled || !!extraData.copyright}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.copyright} defaultValue={extraData.copyright}
placeholder={getLangText('Which copyright is attached to this work?')}/> placeholder={getLangText('Which copyright is attached to this work?')} />
</Property> </Property>
<Property <Property
name='courtesy_of' name='courtesy_of'
label={getLangText('Courtesy of')} label={getLangText('Courtesy of')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.courtesy_of}> expanded={!disabled || !!extraData.courtesy_of}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.courtesy_of} defaultValue={extraData.courtesy_of}
placeholder={getLangText('The current owner of the artwork')}/> placeholder={getLangText('The current owner of the artwork')} />
</Property> </Property>
<Property <Property
name='copyright_of_photography' name='copyright_of_photography'
label={getLangText('Copyright of Photography')} label={getLangText('Copyright of Photography')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.copyright_of_photography}> expanded={!disabled || !!extraData.copyright_of_photography}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.copyright_of_photography} defaultValue={extraData.copyright_of_photography}
placeholder={getLangText('Who should be attributed for the photography?')}/> placeholder={getLangText('Who should be attributed for the photography?')} />
</Property> </Property>
<Property <Property
name='additional_details' name='additional_details'
label={getLangText('Additional Details about the artwork')} label={getLangText('Additional Details about the artwork')}
expanded={!this.props.disabled || !!this.props.piece.extra_data.additional_details}> expanded={!disabled || !!extraData.additional_details}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={this.props.piece.extra_data.additional_details} defaultValue={extraData.additional_details}
placeholder={getLangText('Insert artwork overview')}/> placeholder={getLangText('Insert artwork overview')} />
</Property> </Property>
</Form> </Form>
); );

View File

@ -38,10 +38,17 @@ let IkonotvPieceList = React.createClass({
this.setState(state); this.setState(state);
}, },
redirectIfNoContractNotifications() { shouldRedirect(pieceCount) {
const { currentUser: { email: userEmail },
whitelabel: {
user: whitelabelAdminEmail
} } = this.props;
const { contractAgreementListNotifications } = this.state; const { contractAgreementListNotifications } = this.state;
return contractAgreementListNotifications && !contractAgreementListNotifications.length; return contractAgreementListNotifications &&
!contractAgreementListNotifications.length &&
userEmail !== whitelabelAdminEmail &&
!pieceCount;
}, },
render() { render() {
@ -51,8 +58,6 @@ let IkonotvPieceList = React.createClass({
<div> <div>
<PieceList <PieceList
{...this.props} {...this.props}
redirectTo="/register_piece?slide_num=0"
shouldRedirect={this.redirectIfNoContractNotifications}
accordionListItemType={IkonotvAccordionListItem} accordionListItemType={IkonotvAccordionListItem}
filterParams={[{ filterParams={[{
label: getLangText('Show works I have'), label: getLangText('Show works I have'),
@ -66,7 +71,14 @@ let IkonotvPieceList = React.createClass({
label: getLangText('loaned') label: getLangText('loaned')
} }
] ]
}]} /> }]}
redirectTo={{
pathname: '/register_piece',
query: {
'slide_num': 0
}
}}
shouldRedirect={this.shouldRedirect} />
</div> </div>
); );
} }

View File

@ -47,7 +47,7 @@ let IkonotvRegisterPiece = React.createClass({
getInitialState() { getInitialState() {
return mergeOptions( return mergeOptions(
PieceListStore.getState(), PieceListStore.getState(),
PieceStore.getState(), PieceStore.getInitialState(),
{ {
step: 0, step: 0,
pageExitWarning: getLangText("If you leave this form now, your work will not be loaned to Ikono TV.") pageExitWarning: getLangText("If you leave this form now, your work will not be loaned to Ikono TV.")
@ -59,9 +59,7 @@ let IkonotvRegisterPiece = React.createClass({
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
PieceStore.listen(this.onChange); PieceStore.listen(this.onChange);
// Before we load the new piece, we reset the piece store to delete old data that we do const queryParams = this.props.location.query;
// not want to display to the user.
PieceActions.updatePiece({});
// Since every step of this register process is atomic, // Since every step of this register process is atomic,
// we may need to enter the process at step 1 or 2. // we may need to enter the process at step 1 or 2.
@ -70,9 +68,8 @@ let IkonotvRegisterPiece = React.createClass({
// //
// We're using 'in' here as we want to know if 'piece_id' is present in the url, // We're using 'in' here as we want to know if 'piece_id' is present in the url,
// we don't care about the value. // we don't care about the value.
const queryParams = this.props.location.query; if ('piece_id' in queryParams) {
if (queryParams && 'piece_id' in queryParams) { PieceActions.fetchPiece(queryParams.piece_id);
PieceActions.fetchOne(queryParams.piece_id);
} }
}, },
@ -86,33 +83,30 @@ let IkonotvRegisterPiece = React.createClass({
}, },
handleRegisterSuccess(response){ handleRegisterSuccess(response) {
this.refreshPieceList(); this.refreshPieceList();
// also start loading the piece for the next step // Also load the newly registered piece for the next step
if (response && response.piece) { if (response && response.piece) {
PieceActions.updatePiece(response.piece); PieceActions.updatePiece(response.piece);
} }
if (!this.canSubmit()) { if (!this.canSubmit()) {
this.history.push('/collection'); this.history.push('/collection');
} else { } else {
this.incrementStep(); this.nextSlide({ piece_id: response.piece.id });
this.refs.slidesContainer.nextSlide();
} }
}, },
handleAdditionalDataSuccess() { handleAdditionalDataSuccess() {
// We need to refetch the piece again after submitting the additional data // We need to refetch the piece again after submitting the additional data
// since we want it's otherData to be displayed when the user choses to click // since we want it's otherData to be displayed when the user choses to click
// on the browsers back button. // on the browsers back button.
PieceActions.fetchOne(this.state.piece.id); PieceActions.fetchPiece(this.state.piece.id);
this.refreshPieceList(); this.refreshPieceList();
this.incrementStep(); this.nextSlide();
this.refs.slidesContainer.nextSlide();
}, },
handleLoanSuccess(response) { handleLoanSuccess(response) {
@ -123,33 +117,27 @@ let IkonotvRegisterPiece = React.createClass({
this.refreshPieceList(); this.refreshPieceList();
PieceActions.fetchOne(this.state.piece.id);
this.history.push(`/pieces/${this.state.piece.id}`); this.history.push(`/pieces/${this.state.piece.id}`);
}, },
// We need to increase the step to lock the forms that are already filled out nextSlide(queryParams) {
incrementStep() { // We need to increase the step to lock the forms that are already filled out
// also increase step
let newStep = this.state.step + 1;
this.setState({ this.setState({
step: newStep step: this.state.step + 1
}); });
this.refs.slidesContainer.nextSlide(queryParams);
}, },
refreshPieceList() { refreshPieceList() {
PieceListActions.fetchPieceList( const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.page,
this.state.pageSize, PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.searchTerm,
this.state.orderBy,
this.state.orderAsc,
this.state.filterBy
);
}, },
canSubmit() { canSubmit() {
const { currentUser, whitelabel } = this.props; const { currentUser, whitelabel } = this.props;
return currentUser && currentUser.acl && currentUser.acl.acl_wallet_submit && whitelabel && whitelabel.user; return currentUser.acl && currentUser.acl.acl_wallet_submit && whitelabel.user;
}, },
getSlideArtistDetails() { getSlideArtistDetails() {
@ -160,13 +148,14 @@ let IkonotvRegisterPiece = React.createClass({
<Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}> <Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}>
<IkonotvArtistDetailsForm <IkonotvArtistDetailsForm
handleSuccess={this.handleAdditionalDataSuccess} handleSuccess={this.handleAdditionalDataSuccess}
piece={this.state.piece}/> piece={this.state.piece} />
</Col> </Col>
</Row> </Row>
</div> </div>
); );
} else {
return null;
} }
return null;
}, },
getSlideArtworkDetails() { getSlideArtworkDetails() {
@ -177,13 +166,14 @@ let IkonotvRegisterPiece = React.createClass({
<Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}> <Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}>
<IkonotvArtworkDetailsForm <IkonotvArtworkDetailsForm
handleSuccess={this.handleAdditionalDataSuccess} handleSuccess={this.handleAdditionalDataSuccess}
piece={this.state.piece}/> piece={this.state.piece} />
</Col> </Col>
</Row> </Row>
</div> </div>
); );
} else {
return null;
} }
return null;
}, },
getSlideLoan() { getSlideLoan() {
@ -214,8 +204,9 @@ let IkonotvRegisterPiece = React.createClass({
</Row> </Row>
</div> </div>
); );
} else {
return null;
} }
return null;
}, },
render() { render() {

View File

@ -3,7 +3,7 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PieceActions from '../../../../../../actions/piece_actions'; import EditionActions from '../../../../../../actions/edition_actions';
import MarketAdditionalDataForm from '../market_forms/market_additional_data_form'; import MarketAdditionalDataForm from '../market_forms/market_additional_data_form';
@ -24,30 +24,28 @@ let MarketSubmitButton = React.createClass({
availableAcls: React.PropTypes.object.isRequired, availableAcls: React.PropTypes.object.isRequired,
currentUser: React.PropTypes.object.isRequired, currentUser: React.PropTypes.object.isRequired,
editions: React.PropTypes.array.isRequired, editions: React.PropTypes.array.isRequired,
handleSuccess: React.PropTypes.func.isRequired,
whitelabel: React.PropTypes.object.isRequired,
className: React.PropTypes.string, className: React.PropTypes.string,
handleSuccess: React.PropTypes.func
}, },
canEditionBeSubmitted(edition) { canEditionBeSubmitted(edition) {
if (edition && edition.extra_data && edition.other_data) { if (edition && edition.extra_data && edition.other_data) {
const { extra_data, other_data } = edition; const {
extra_data: {
artist_bio: artistBio,
display_instructions: displayInstructions,
technology_details: technologyDetails,
work_description: workDescription
},
other_data: otherData } = edition;
if (extra_data.artist_bio && extra_data.work_description && return artistBio && displayInstructions && technologyDetails && workDescription && otherData.length;
extra_data.technology_details && extra_data.display_instructions &&
other_data.length > 0) {
return true;
}
} }
return false; return false;
}, },
getFormDataId() {
return getAclFormDataId(false, this.props.editions);
},
getAggregateEditionDetails() { getAggregateEditionDetails() {
const { editions } = this.props; const { editions } = this.props;
@ -64,13 +62,20 @@ let MarketSubmitButton = React.createClass({
}); });
}, },
handleAdditionalDataSuccess(pieceId) { getFormDataId() {
// Fetch newly updated piece to update the views return getAclFormDataId(false, this.props.editions);
PieceActions.fetchOne(pieceId); },
handleAdditionalDataSuccess() {
this.refs.consignModal.show(); this.refs.consignModal.show();
}, },
refreshEdition() {
if (this.props.editions.length === 1) {
EditionActions.fetchEdition(this.props.editions[0].bitcoin_id);
}
},
render() { render() {
const { const {
availableAcls, availableAcls,
@ -89,6 +94,10 @@ let MarketSubmitButton = React.createClass({
senderName: currentUser.username senderName: currentUser.username
}); });
// If only a single piece is selected, all the edition's extra_data and other_data will
// be the same, so we just take the first edition's
const { extra_data: extraData, other_data: otherData } = solePieceId ? editions[0] : {};
const triggerButton = ( const triggerButton = (
<button className={classNames('btn', 'btn-default', 'btn-sm', className)}> <button className={classNames('btn', 'btn-default', 'btn-sm', className)}>
{getLangText('CONSIGN TO %s', whitelabelName.toUpperCase())} {getLangText('CONSIGN TO %s', whitelabelName.toUpperCase())}
@ -115,16 +124,25 @@ let MarketSubmitButton = React.createClass({
aclName='acl_consign'> aclName='acl_consign'>
<ModalWrapper <ModalWrapper
trigger={triggerButton} trigger={triggerButton}
handleSuccess={this.handleAdditionalDataSuccess.bind(this, solePieceId)} handleSuccess={this.handleAdditionalDataSuccess}
title={getLangText('Add additional information')}> title={getLangText('Add additional information')}>
<MarketAdditionalDataForm <MarketAdditionalDataForm
extraData={extraData}
otherData={otherData}
pieceId={solePieceId} pieceId={solePieceId}
submitLabel={getLangText('Continue to consignment')} /> submitLabel={getLangText('Continue to consignment')} />
</ModalWrapper> </ModalWrapper>
<ModalWrapper <ModalWrapper
ref="consignModal" ref="consignModal"
handleSuccess={handleSuccess} handleCancel={this.refreshEdition}
handleSuccess={(...params) => {
if (typeof handleSuccess === 'function') {
handleSuccess(...params);
}
this.refreshEdition();
}}
title={getLangText('Consign artwork')}> title={getLangText('Consign artwork')}>
{consignForm} {consignForm}
</ModalWrapper> </ModalWrapper>

View File

@ -6,8 +6,12 @@ import MarketAdditionalDataForm from '../market_forms/market_additional_data_for
let MarketFurtherDetails = React.createClass({ let MarketFurtherDetails = React.createClass({
propTypes: { propTypes: {
pieceId: React.PropTypes.number, pieceId: React.PropTypes.number.isRequired,
editable: React.PropTypes.bool,
extraData: React.PropTypes.object,
handleSuccess: React.PropTypes.func, handleSuccess: React.PropTypes.func,
otherData: React.PropTypes.arrayOf(React.PropTypes.object)
}, },
render() { render() {

View File

@ -2,42 +2,39 @@
import React from 'react'; import React from 'react';
import Form from '../../../../../ascribe_forms/form';
import Property from '../../../../../ascribe_forms/property';
import InputTextAreaToggable from '../../../../../ascribe_forms/input_textarea_toggable';
import FurtherDetailsFileuploader from '../../../../../ascribe_detail/further_details_fileuploader';
import AscribeSpinner from '../../../../../ascribe_spinner';
import GlobalNotificationModel from '../../../../../../models/global_notification_model'; import GlobalNotificationModel from '../../../../../../models/global_notification_model';
import GlobalNotificationActions from '../../../../../../actions/global_notification_actions'; import GlobalNotificationActions from '../../../../../../actions/global_notification_actions';
import { formSubmissionValidation } from '../../../../../ascribe_uploader/react_s3_fine_uploader_utils'; import FurtherDetailsFileuploader from '../../../../../ascribe_detail/further_details_fileuploader';
import PieceActions from '../../../../../../actions/piece_actions'; import InputTextAreaToggable from '../../../../../ascribe_forms/input_textarea_toggable';
import PieceStore from '../../../../../../stores/piece_store'; import Form from '../../../../../ascribe_forms/form';
import Property from '../../../../../ascribe_forms/property';
import AscribeSpinner from '../../../../../ascribe_spinner';
import ApiUrls from '../../../../../../constants/api_urls'; 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 requests from '../../../../../../utils/requests';
import { formSubmissionValidation } from '../../../../../ascribe_uploader/react_s3_fine_uploader_utils';
import { mergeOptions } from '../../../../../../utils/general_utils'; import { mergeOptions } from '../../../../../../utils/general_utils';
import { getLangText } from '../../../../../../utils/lang_utils'; import { getLangText } from '../../../../../../utils/lang_utils';
let MarketAdditionalDataForm = React.createClass({ let MarketAdditionalDataForm = React.createClass({
propTypes: { propTypes: {
pieceId: React.PropTypes.oneOfType([ pieceId: React.PropTypes.number.isRequired,
React.PropTypes.number,
React.PropTypes.string
]),
editable: React.PropTypes.bool, editable: React.PropTypes.bool,
extraData: React.PropTypes.object,
handleSuccess: React.PropTypes.func,
isInline: React.PropTypes.bool, isInline: React.PropTypes.bool,
otherData: React.PropTypes.arrayOf(React.PropTypes.object),
showHeading: React.PropTypes.bool, showHeading: React.PropTypes.bool,
showNotification: React.PropTypes.bool, showNotification: React.PropTypes.bool,
submitLabel: React.PropTypes.string, submitLabel: React.PropTypes.string
handleSuccess: React.PropTypes.func
}, },
getDefaultProps() { getDefaultProps() {
@ -48,50 +45,34 @@ let MarketAdditionalDataForm = React.createClass({
}, },
getInitialState() { getInitialState() {
const pieceStore = PieceStore.getState(); return {
// Allow the form to be submitted if there's already an additional image uploaded
return mergeOptions( isUploadReady: this.isUploadReadyOnChange(),
pieceStore, forceUpdateKey: 0
{
// Allow the form to be submitted if there's already an additional image uploaded
isUploadReady: this.isUploadReadyOnChange(pieceStore.piece),
forceUpdateKey: 0
});
},
componentDidMount() {
PieceStore.listen(this.onChange);
if (this.props.pieceId) {
PieceActions.fetchOne(this.props.pieceId);
} }
}, },
componentWillUnmount() { componentWillReceiveProps(nextProps) {
PieceStore.unlisten(this.onChange); if (this.props.extraData !== nextProps.extraData || this.props.otherData !== nextProps.otherData) {
}, this.setState({
// Allow the form to be submitted if the updated piece has an additional image uploaded
isUploadReady: this.isUploadReadyOnChange(),
onChange(state) { /**
Object.assign({}, state, { * Increment the forceUpdateKey to force the form to rerender on each change
// Allow the form to be submitted if the updated piece already has an additional image uploaded *
isUploadReady: this.isUploadReadyOnChange(state.piece), * THIS IS A HACK TO MAKE SURE THE FORM ALWAYS DISPLAYS THE MOST RECENT STATE
* BECAUSE SOME OF OUR FORM ELEMENTS DON'T UPDATE FROM PROP CHANGES (ie.
/** * InputTextAreaToggable).
* Increment the forceUpdateKey to force the form to rerender on each change */
* forceUpdateKey: this.state.forceUpdateKey + 1
* THIS IS A HACK TO MAKE SURE THE FORM ALWAYS DISPLAYS THE MOST RECENT STATE });
* BECAUSE SOME OF OUR FORM ELEMENTS DON'T UPDATE FROM PROP CHANGES (ie. }
* InputTextAreaToggable).
*/
forceUpdateKey: this.state.forceUpdateKey + 1
});
this.setState(state);
}, },
getFormData() { getFormData() {
let extradata = {}; const extradata = {};
let formRefs = this.refs.form.refs; const formRefs = this.refs.form.refs;
// Put additional fields in extra data object // Put additional fields in extra data object
Object Object
@ -102,12 +83,12 @@ let MarketAdditionalDataForm = React.createClass({
return { return {
extradata: extradata, extradata: extradata,
piece_id: this.state.piece.id piece_id: this.props.pieceId
}; };
}, },
isUploadReadyOnChange(piece) { isUploadReadyOnChange() {
return piece && piece.other_data && piece.other_data.length > 0; return this.props.otherData && this.props.otherData.length;
}, },
handleSuccessWithNotification() { handleSuccessWithNotification() {
@ -126,12 +107,20 @@ let MarketAdditionalDataForm = React.createClass({
}, },
render() { render() {
const { editable, isInline, handleSuccess, showHeading, showNotification, submitLabel } = this.props; const {
const { piece } = this.state; editable,
extraData = {},
isInline,
handleSuccess,
otherData,
pieceId,
showHeading,
showNotification,
submitLabel } = this.props;
let buttons; let buttons;
let heading; let heading;
let spinner;
let spinner = <AscribeSpinner color='dark-blue' size='lg' />;
if (!isInline) { if (!isInline) {
buttons = ( buttons = (
@ -146,7 +135,7 @@ let MarketAdditionalDataForm = React.createClass({
spinner = ( spinner = (
<div className="modal-footer"> <div className="modal-footer">
<p className="pull-right"> <p className="pull-right">
{spinner} <AscribeSpinner color='dark-blue' size='md' />
</p> </p>
</div> </div>
); );
@ -160,64 +149,70 @@ let MarketAdditionalDataForm = React.createClass({
) : null; ) : null;
} }
if (piece && piece.id) { if (pieceId) {
return ( return (
<Form <Form
className="ascribe-form-bordered" className="ascribe-form-bordered"
ref='form' ref='form'
key={this.state.forceUpdateKey} key={this.state.forceUpdateKey}
url={requests.prepareUrl(ApiUrls.piece_extradata, {piece_id: piece.id})} url={requests.prepareUrl(ApiUrls.piece_extradata, { piece_id: pieceId })}
handleSuccess={showNotification ? this.handleSuccessWithNotification : handleSuccess} handleSuccess={showNotification ? this.handleSuccessWithNotification : handleSuccess}
getFormData={this.getFormData} getFormData={this.getFormData}
buttons={buttons} buttons={buttons}
spinner={spinner} spinner={spinner}
disabled={!this.props.editable || !piece.acl.acl_edit}> disabled={!this.props.editable}>
{heading} {heading}
<FurtherDetailsFileuploader <FurtherDetailsFileuploader
label={getLangText('Marketplace Thumbnail Image')} areAssetsDownloadable={!!isInline}
submitFile={function () {}} editable={editable}
setIsUploadReady={this.setIsUploadReady}
isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile} isReadyForFormSubmission={formSubmissionValidation.atLeastOneUploadedFile}
pieceId={piece.id} label={getLangText('Marketplace Thumbnail Image')}
otherData={piece.other_data} otherData={otherData}
editable={editable} /> pieceId={pieceId}
setIsUploadReady={this.setIsUploadReady}
submitFile={function () {}}
validation={{
itemLimit: validationTypes.workThumbnail.itemLimit,
sizeLimit: validationTypes.workThumbnail.sizeLimit,
allowedExtensions: validationParts.allowedExtensions.images
}} />
<Property <Property
name='artist_bio' name='artist_bio'
label={getLangText('Artist Bio')} label={getLangText('Artist Bio')}
expanded={editable || !!piece.extra_data.artist_bio}> expanded={editable || !!extraData.artist_bio}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.artist_bio} defaultValue={extraData.artist_bio}
placeholder={getLangText('Enter a biography of the artist...')} placeholder={getLangText('Enter a biography of the artist...')}
required /> required />
</Property> </Property>
<Property <Property
name='work_description' name='work_description'
label={getLangText('Work Description')} label={getLangText('Work Description')}
expanded={editable || !!piece.extra_data.work_description}> expanded={editable || !!extraData.work_description}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.work_description} defaultValue={extraData.work_description}
placeholder={getLangText('Enter a description of the work...')} placeholder={getLangText('Enter a description of the work...')}
required /> required />
</Property> </Property>
<Property <Property
name='technology_details' name='technology_details'
label={getLangText('Technology Details')} label={getLangText('Technology Details')}
expanded={editable || !!piece.extra_data.technology_details}> expanded={editable || !!extraData.technology_details}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.technology_details} defaultValue={extraData.technology_details}
placeholder={getLangText('Enter technological details about the work...')} placeholder={getLangText('Enter technological details about the work...')}
required /> required />
</Property> </Property>
<Property <Property
name='display_instructions' name='display_instructions'
label={getLangText('Display Instructions')} label={getLangText('Display Instructions')}
expanded={editable || !!piece.extra_data.display_instructions}> expanded={editable || !!extraData.display_instructions}>
<InputTextAreaToggable <InputTextAreaToggable
rows={1} rows={1}
defaultValue={piece.extra_data.display_instructions} defaultValue={extraData.display_instructions}
placeholder={getLangText('Enter instructions on how to best display the work...')} placeholder={getLangText('Enter instructions on how to best display the work...')}
required /> required />
</Property> </Property>
@ -226,7 +221,7 @@ let MarketAdditionalDataForm = React.createClass({
} else { } else {
return ( return (
<div className="ascribe-loading-position"> <div className="ascribe-loading-position">
{spinner} <AscribeSpinner color='dark-blue' size='lg' />
</div> </div>
); );
} }

View File

@ -35,11 +35,12 @@ let MarketPieceList = React.createClass({
} } = this.props; } } = this.props;
let filterParams = null; let filterParams = null;
let isUserAdmin = null;
let canLoadPieceList = false; let canLoadPieceList = false;
if (userEmail && whitelabelAdminEmail) { if (userEmail && whitelabelAdminEmail) {
canLoadPieceList = true; canLoadPieceList = true;
const isUserAdmin = userEmail === whitelabelAdminEmail; isUserAdmin = userEmail === whitelabelAdminEmail;
filterParams = [{ filterParams = [{
label: getLangText('Show works I can'), label: getLangText('Show works I can'),
@ -55,7 +56,13 @@ let MarketPieceList = React.createClass({
<PieceList <PieceList
{...this.props} {...this.props}
canLoadPieceList={canLoadPieceList} canLoadPieceList={canLoadPieceList}
redirectTo="/register_piece?slide_num=0" redirectTo={{
pathname: '/register_piece',
query: {
'slide_num': 0
}
}}
shouldRedirect={(pieceCount) => !isUserAdmin && !pieceCount}
bulkModalButtonListType={MarketAclButtonList} bulkModalButtonListType={MarketAclButtonList}
filterParams={filterParams} /> filterParams={filterParams} />
); );

View File

@ -6,10 +6,12 @@ import { History } from 'react-router';
import Col from 'react-bootstrap/lib/Col'; import Col from 'react-bootstrap/lib/Col';
import Row from 'react-bootstrap/lib/Row'; import Row from 'react-bootstrap/lib/Row';
import PieceActions from '../../../../../actions/piece_actions';
import PieceListStore from '../../../../../stores/piece_list_store'; import PieceListStore from '../../../../../stores/piece_list_store';
import PieceListActions from '../../../../../actions/piece_list_actions'; import PieceListActions from '../../../../../actions/piece_list_actions';
import PieceStore from '../../../../../stores/piece_store';
import PieceActions from '../../../../../actions/piece_actions';
import MarketAdditionalDataForm from './market_forms/market_additional_data_form'; import MarketAdditionalDataForm from './market_forms/market_additional_data_form';
import Property from '../../../../ascribe_forms/property'; import Property from '../../../../ascribe_forms/property';
@ -36,6 +38,7 @@ let MarketRegisterPiece = React.createClass({
getInitialState(){ getInitialState(){
return mergeOptions( return mergeOptions(
PieceListStore.getState(), PieceListStore.getState(),
PieceStore.getInitialState(),
{ {
step: 0 step: 0
}); });
@ -43,14 +46,22 @@ let MarketRegisterPiece = React.createClass({
componentDidMount() { componentDidMount() {
PieceListStore.listen(this.onChange); PieceListStore.listen(this.onChange);
PieceStore.listen(this.onChange);
// Reset the piece store to make sure that we don't display old data const queryParams = this.props.location.query;
// if the user repeatedly registers works
PieceActions.updatePiece({}); // Load the correct piece if the user loads the second step directly
// by pressing on the back button or using the url
// We're using 'in' here as we want to know if 'piece_id' is present in the url,
// we don't care about the value.
if ('piece_id' in queryParams) {
PieceActions.fetchPiece(queryParams.piece_id);
}
}, },
componentWillUnmount() { componentWillUnmount() {
PieceListStore.unlisten(this.onChange); PieceListStore.unlisten(this.onChange);
PieceStore.unlisten(this.onChange);
}, },
onChange(state) { onChange(state) {
@ -60,15 +71,12 @@ let MarketRegisterPiece = React.createClass({
handleRegisterSuccess(response) { handleRegisterSuccess(response) {
this.refreshPieceList(); this.refreshPieceList();
// Use the response's piece for the next step if available // Also load the newly registered piece for the next step
let pieceId = null;
if (response && response.piece) { if (response && response.piece) {
pieceId = response.piece.id;
PieceActions.updatePiece(response.piece); PieceActions.updatePiece(response.piece);
} }
this.incrementStep(); this.nextSlide({ piece_id: response.piece.id });
this.refs.slidesContainer.nextSlide({ piece_id: pieceId });
}, },
handleAdditionalDataSuccess() { handleAdditionalDataSuccess() {
@ -77,42 +85,28 @@ let MarketRegisterPiece = React.createClass({
this.history.push('/collection'); this.history.push('/collection');
}, },
// We need to increase the step to lock the forms that are already filled out nextSlide(queryParams) {
incrementStep() { // We need to increase the step to lock the forms that are already filled out
this.setState({ this.setState({
step: this.state.step + 1 step: this.state.step + 1
}); });
},
getPieceFromQueryParam() { this.refs.slidesContainer.nextSlide(queryParams);
const queryParams = this.props.location.query;
// Since every step of this register process is atomic,
// we may need to enter the process at step 1 or 2.
// If this is the case, we'll need the piece number to complete submission.
// It is encoded in the URL as a queryParam and we're checking for it here.
return queryParams && queryParams.piece_id;
}, },
refreshPieceList() { refreshPieceList() {
PieceListActions.fetchPieceList( const { filterBy, orderAsc, orderBy, page, pageSize, search } = this.state;
this.state.page,
this.state.pageSize, PieceListActions.fetchPieceList({ page, pageSize, search, orderBy, orderAsc, filterBy });
this.state.searchTerm,
this.state.orderBy,
this.state.orderAsc,
this.state.filterBy
);
}, },
render() { render() {
const { const {
currentUser,
location, location,
whitelabel: { whitelabel: {
name: whitelabelName = 'Market' name: whitelabelName = 'Market'
} } = this.props } } = this.props
const { step } = this.state; const { piece, step } = this.state;
setDocumentTitle(getLangText('Register a new piece')); setDocumentTitle(getLangText('Register a new piece'));
@ -129,15 +123,14 @@ let MarketRegisterPiece = React.createClass({
<Row className="no-margin"> <Row className="no-margin">
<Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}> <Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}>
<RegisterPieceForm <RegisterPieceForm
currentUser={currentUser} {...this.props}
disabled={step > 0} disabled={step > 0}
enableLocalHashing={false} enableLocalHashing={false}
headerMessage={getLangText('Consign to %s', whitelabelName)}
submitMessage={getLangText('Proceed to additional details')}
isFineUploaderActive={true}
enableSeparateThumbnail={false} enableSeparateThumbnail={false}
handleSuccess={this.handleRegisterSuccess} handleSuccess={this.handleRegisterSuccess}
location={location}> headerMessage={getLangText('Consign to %s', whitelabelName)}
isFineUploaderActive={true}
submitMessage={getLangText('Proceed to additional details')}>
<Property <Property
name="num_editions" name="num_editions"
label={getLangText('Specify editions')}> label={getLangText('Specify editions')}>
@ -155,8 +148,10 @@ let MarketRegisterPiece = React.createClass({
<Row className="no-margin"> <Row className="no-margin">
<Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}> <Col xs={12} sm={10} md={8} smOffset={1} mdOffset={2}>
<MarketAdditionalDataForm <MarketAdditionalDataForm
extraData={piece.extra_data}
handleSuccess={this.handleAdditionalDataSuccess} handleSuccess={this.handleAdditionalDataSuccess}
pieceId={this.getPieceFromQueryParam()} otherData={piece.other_data}
pieceId={piece.id}
showHeading /> showHeading />
</Col> </Col>
</Row> </Row>

Some files were not shown because too many files have changed in this diff Show More