Skip to content

Commit

Permalink
Replace apply for beta form with separate component to validate project
Browse files Browse the repository at this point in the history
properties, including tests
  • Loading branch information
rogerhutchings committed Mar 7, 2017
1 parent 104968b commit 888b7b4
Show file tree
Hide file tree
Showing 5 changed files with 640 additions and 119 deletions.
7 changes: 7 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
"plugins": [
["transform-react-jsx"]
]
},
"test": {
"plugins": [
["babel-plugin-rewire"],
["transform-react-jsx"],
["typecheck"]
]
}
}
}
243 changes: 243 additions & 0 deletions app/pages/lab/apply-for-beta-form.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import React from 'react';
import uniq from 'lodash.uniq';
import apiClient from 'panoptes-client/lib/api-client';

const MINIMUM_SUBJECT_COUNT = 100;
const REQUIRED_PAGES = ['Research', 'FAQ'];

class ApplyForBetaForm extends React.Component {
constructor(props) {
super(props);

this.attemptApplyForBeta = this.attemptApplyForBeta.bind(this);
this.canApplyForReview = this.canApplyForReview.bind(this);
this.createCheckbox = this.createCheckbox.bind(this);
this.projectHasActiveWorkflows = this.projectHasActiveWorkflows.bind(this);
this.projectHasMinimumActiveSubjects = this.projectHasMinimumActiveSubjects.bind(this);
this.projectHasRequiredContent = this.projectHasRequiredContent.bind(this);
this.projectIsLive = this.projectIsLive.bind(this);
this.projectIsPublic = this.projectIsPublic.bind(this);
this.renderValidationErrors = this.renderValidationErrors.bind(this);
this.testAsyncValidations = this.testAsyncValidations.bind(this);
this.toggleValidation = this.toggleValidation.bind(this);
this.updateValidationsFromProps = this.updateValidationsFromProps.bind(this);

this.state = {
validations: {
projectIsPublic: this.projectIsPublic(props.project),
projectIsLive: this.projectIsLive(props.project),
projectHasActiveWorkflows: this.projectHasActiveWorkflows(props.workflows),
labPolicyReviewed: false,
bestPracticesReviewed: false,
feedbackReviewed: false,
},
validationErrors: [],
doingAsyncValidation: false,
};
}

attemptApplyForBeta() {
this.testAsyncValidations()
.then(() => {
this.props.applyFn();
})
.catch(errors => {
this.setState({
validationErrors: errors,
});
});
}

projectHasMinimumActiveSubjects(workflows) {
const activeWorkflows = workflows.filter(workflow => workflow.active);
const uniqueSetIDs = uniq(activeWorkflows.map(workflow => workflow.links.subject_sets));
// Second parameter is an empty object to prevent request caching.
return apiClient.type('subject_sets', {})
.get(uniqueSetIDs)
.then(sets => {
const subjectCount = sets.reduce((count, set) => count + set.set_member_subjects_count, 0);
return (subjectCount >= MINIMUM_SUBJECT_COUNT) ? true : `The project only has ${subjectCount} of ${MINIMUM_SUBJECT_COUNT} required subjects`;
});
}

projectHasRequiredContent(project) {
// Second parameter is an empty object to prevent request caching.
return apiClient.type('projects')
.get(project.id)
.get('pages', {})
.then(projectPages => {
const missingPages = REQUIRED_PAGES.reduce((accumulator, requiredPage) => {
const pagePresent = projectPages.find(page => requiredPage === page.title);
if (!pagePresent || (pagePresent.content === null || pagePresent.content === ''))
accumulator.push(requiredPage);
return accumulator;
}, []);
return (missingPages.length === 0) ? true : 'The following pages are missing content: ' + missingPages.join(', ');
});
}

testAsyncValidations() {
// Resolves to true if everything passes, else rejects with an array of
// error messages.
this.setState({ doingAsyncValidation: true });
return Promise.all([
this.projectHasMinimumActiveSubjects(this.props.workflows),
this.projectHasRequiredContent(this.props.project),
])
.catch(error => console.error('Error requesting project data', error))
.then(results => {
this.setState({ doingAsyncValidation: false });
if (results.every(result => typeof result === 'boolean' && result === true)) {
return true;
}
const errors = results.filter(result => typeof result !== 'boolean');
return Promise.reject(errors);
})
}

projectIsPublic(project) {
return project.private === false;
}

projectIsLive(project) {
return project.live === true;
}

projectHasActiveWorkflows(workflows) {
return workflows.some(workflow => workflow.active);
}

toggleValidation(validationName, event) {
const validations = Object.assign({}, this.state.validations);
validations[validationName] = event.target.checked;
this.setState({ validations });
}

canApplyForReview() {
const { validations } = this.state;
const values = Object.keys(validations)
.map(key => validations[key]);
return values.every(value => value === true);
}

createCheckbox(validationName, content, disabled = false) {
// If it's a non-user controlled checkbox, we don't want to trigger anything
// on change, so we use Function.prototype as a noop.
const changeFn = (disabled) ? Function.prototype : this.toggleValidation.bind(this, validationName);
return (
<label style={{ display: 'block' }}>
<input type="checkbox"
onChange={changeFn}
checked={this.state.validations[validationName] === true}
disabled={disabled}
/>
{content}
</label>
);
}

componentWillUpdate(nextProps) {
this.updateValidationsFromProps(nextProps);
}

updateValidationsFromProps(props) {
// We need to do a props comparison, otherwise we get a loop where props
// update state -> updates state repeatedly.
//
// Unfortunately, we have to do it by comparing the new props against the
// current state, instead of using shouldComponentUpdate. This is because
// the project prop passed down is mutable, which breaks the props/nextProps
// comparison used by shouldComponentUpdate.
const validations = Object.assign({}, this.state.validations);

const newValues = {
projectIsPublic: this.projectIsPublic(props.project),
projectIsLive: this.projectIsLive(props.project),
projectHasActiveWorkflows: this.projectHasActiveWorkflows(props.workflows),
};

for (let key in newValues) {
if (validations[key] !== newValues[key])
validations[key] = newValues[key];
}

if (!shallowCompare(validations, this.state.validations))
this.setState({ validations });
}

renderValidationErrors(errors) {
if (errors.length) {
return (
<div>
<p className="form-help">The following errors need to be fixed:</p>
<ul className="form-help error-messages">
{errors.map(error => <li key={error}>{error}</li>)}
</ul>
</div>
);
}
return null;
}

render() {
const applyButtonDisabled = !this.canApplyForReview() ||
this.state.doingAsyncValidation;

return (
<div>

{this.createCheckbox('projectIsPublic', <span>Project is public</span>, true)}

{this.createCheckbox('projectIsLive', <span>Project is live</span>, true)}

{this.createCheckbox('projectHasActiveWorkflows', <span>Project has at least one active workflow</span>, true)}

{this.createCheckbox('labPolicyReviewed', <span>I have reviewed the <a href="/lab-policies" target="_blank">policies</a></span>)}

{this.createCheckbox('bestPracticesReviewed', <span>I have reviewed the <a href="/lab-best-practices" target="_blank">best practices</a></span>)}

{this.createCheckbox('feedbackReviewed', <span>I have reviewed the sample <a href="https://docs.google.com/a/zooniverse.org/forms/d/1o7yTqpytWWhSOqQhJYiKaeHIaax7xYVUyTOaG3V0xA4/viewform" target="_blank">project review feedback form</a></span>)}

<p className="form-help">To be eligible for beta review, projects also require:</p>
<ul className="form-help">
<li>at least {MINIMUM_SUBJECT_COUNT} subjects in active workflows</li>
<li>content on the Research and FAQ pages in the About page</li>
</ul>
<p className="form-help">These will be checked when you click "Apply for review".</p>

<button
type="button"
className="standard-button"
disabled={applyButtonDisabled}
onClick={this.attemptApplyForBeta}
>
Apply for review
</button>

{this.renderValidationErrors(this.state.validationErrors)}

</div>
);

}
}

ApplyForBetaForm.defaultProps = {
project: {},
workflows: [],
}

export default ApplyForBetaForm;

// Helper function for comparing objects
const shallowCompare = (a, b) => {
for (let key in a) {
if (!(key in b) || a[key] !== b[key])
return false;
}
for (let key in b) {
if(!(key in a) || a[key] !== b[key])
return false;
}
return true;
}
Loading

0 comments on commit 888b7b4

Please sign in to comment.