Repository: zooniverse/Panoptes-Front-End
Pull request #5045 (2018-11-05 20:14:19)

Test Drawing Tool API
Staging branch URL:

Describe your changes.
This PR adds a loop through all drawing tools to test basic functionality of the drawing tool API. As all drawing tools perform differently, the checks made here are all very general.

This is a basic first stab, and I'm open to any suggestions to improve these tests.

# Required Manual Testing

- [ ] Does the non-logged in home page render correctly?
- [ ] Does the logged in home page render correctly?
- [ ] Does the projects page render correctly?
- [ ] Can you load project home pages?
- [ ] Can you load the classification page?
- [ ] Can you submit a classification?
- [ ] Does talk load correctly?
- [ ] Can you post a talk comment?

# Review Checklist

- [ ] Does it work in all major browsers: Firefox, Chrome, Edge, Safari?
- [ ] Does it work on mobile?
- [ ] Can you `rm -rf node_modules/ && npm install` and app works as expected?
- [ ] If the component is in coffeescript, is it converted to ES6? Is it free of eslint errors? Is the conversion its own commit?
- [ ] Are the tests passing locally and on Travis?

# Optional

- [ ] Have you replaced any `ChangeListener` or `PromiseRenderer` components with code that updates component state?
- [ ] If changes are made to the classifier, does the dev classifier still work?
- [ ] Have you [resized and compressed](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/image-optimization) any image you've added?
- [ ] Have you added in [flow type annotations](https://flowtype.org/docs/type-annotations.html)?
- [ ] Have you followed the [Springer guidelines for commit messages](https://github.com/springernature/frontend-playbook/blob/master/git/git.md#commit-messages)?



Patch:
diff --git a/app/classifier/drawing-tools/components/Fan/fan.spec.jsx b/app/classifier/drawing-tools/components/Fan/fan.spec.jsx
index 102e1f20..270f09f2 100644
--- a/app/classifier/drawing-tools/components/Fan/fan.spec.jsx
+++ b/app/classifier/drawing-tools/components/Fan/fan.spec.jsx
@@ -122,7 +122,7 @@ describe('Fan Tool', function () {
     });
 
     describe('the spread drag handles', function () {
-        
+
       afterEach(function () {
         onChange.resetHistory();
       });
@@ -179,4 +179,4 @@ describe('Fan Tool', function () {
       });
     });
   });
-});
\ No newline at end of file
+});
diff --git a/app/classifier/tasks/drawing/marking-initializer.cjsx b/app/classifier/tasks/drawing/marking-initializer.cjsx
index 18a7065b..efcf6d57 100644
--- a/app/classifier/tasks/drawing/marking-initializer.cjsx
+++ b/app/classifier/tasks/drawing/marking-initializer.cjsx
@@ -82,7 +82,7 @@ module.exports = createReactClass
     @onChange()
 
   handleInitRelease: (e) ->
-    pref = @props.preferences.preferences
+    pref = @props.preferences?.preferences ? {}
     taskDescription = @props.workflow.tasks[@props.annotation.task]
     mark = @props.annotation.value[@props.annotation.value.length - 1]
     MarkComponent = drawingTools[taskDescription.tools[mark.tool].type]
diff --git a/app/classifier/tasks/text/index.jsx b/app/classifier/tasks/text/index.jsx
index bfedffc1..e660c412 100644
--- a/app/classifier/tasks/text/index.jsx
+++ b/app/classifier/tasks/text/index.jsx
@@ -9,7 +9,7 @@ import TextTaskSummary from './summary';
 const LINEHEIGHT = 22.5;
 const NOOP = Function.prototype;
 
-export default class TextTask extends React.Component {
+export default class TextTask extends React.PureComponent {
   constructor(props) {
     super(props);
 
@@ -77,9 +77,9 @@ export default class TextTask extends React.Component {
   handleChange() {
     const value = this.textInput.current.value;
     if (value.length < this.state.value.length) {
-      this.setState({ rows: 1, value }, this.handleResize);
+      this.setState({ rows: 1, value });
     } else {
-      this.setState({ value }, this.handleResize);
+      this.setState({ value });
     }
 
     this.debouncedUpdateAnnotation(value);
diff --git a/app/classifier/tasks/text/index.spec.js b/app/classifier/tasks/text/index.spec.js
index c2e5c859..ff87336e 100644
--- a/app/classifier/tasks/text/index.spec.js
+++ b/app/classifier/tasks/text/index.spec.js
@@ -39,12 +39,14 @@ describe('TextTask', function () {
 
   describe('input onChange event handler', function () {
     let handleChangeSpy;
+    let handleResizeSpy;
     let onChangeSpy;
     let setStateSpy;
     let wrapper;
 
     before(function () {
       handleChangeSpy = sinon.spy(TextTask.prototype, 'handleChange');
+      handleResizeSpy = sinon.spy(TextTask.prototype, 'handleResize');
       onChangeSpy = sinon.spy();
       setStateSpy = sinon.spy(TextTask.prototype, 'setState');
     });
@@ -60,12 +62,14 @@ describe('TextTask', function () {
 
     afterEach(function () {
       handleChangeSpy.resetHistory();
+      handleResizeSpy.resetHistory();
       onChangeSpy.resetHistory();
       setStateSpy.resetHistory();
     });
 
     after(function () {
       handleChangeSpy.restore();
+      handleResizeSpy.restore();
       setStateSpy.restore();
     });
 
@@ -89,6 +93,16 @@ describe('TextTask', function () {
       wrapper.find('textarea').first().simulate('change', { target: { value: 'text change' }});
       expect(handleChangeSpy.calledOnce).to.be.true;
     });
+    it('should call handleResize after an onChange event', function () {
+      wrapper.instance().textInput.current.value = 'text change';
+      wrapper.find('textarea').first().simulate('change', { target: { value: 'text change' }});
+      expect(handleResizeSpy.calledOnce).to.be.true;
+    });
+    it('should not call handleResize if state doesn\'t change', function () {
+      wrapper.instance().textInput.current.value = 'testing the [deletion]text task[/deletion] tag selection';
+      wrapper.find('textarea').first().simulate('change', { target: { value: 'testing the [deletion]text task[/deletion] tag selection' }});
+      expect(handleResizeSpy.called).to.be.false;
+    });
   });
 
   describe('static methods', function () {
diff --git a/app/components/wrapped-markdown.jsx b/app/components/wrapped-markdown.jsx
index 71403995..99a39dba 100644
--- a/app/components/wrapped-markdown.jsx
+++ b/app/components/wrapped-markdown.jsx
@@ -13,10 +13,12 @@ class WrappedMarkdown extends React.Component {
   onClick = (e) => {
     const rightButtonPressed = (!!e.button && e.button > 0);
     const modifierKey = (e.ctrlKey || e.metaKey);
+    const hasNamedTarget = e.target.target;
     if (e.target.origin === window.location.origin &&
       e.target.pathname !== window.location.pathname &&
       !rightButtonPressed &&
-      !modifierKey) {
+      !modifierKey &&
+      !hasNamedTarget) {
       const newURL = e.target.pathname + e.target.search + e.target.hash;
       browserHistory.push(newURL);
       e.preventDefault();
diff --git a/app/pages/project/classify.jsx b/app/pages/project/classify.jsx
index c2c8380d..a74b14d0 100644
--- a/app/pages/project/classify.jsx
+++ b/app/pages/project/classify.jsx
@@ -185,13 +185,20 @@ export class ProjectClassifyPage extends React.Component {
   }
 
   maybePromptWorkflowAssignmentDialog(props) {
+    const { actions, preferences, project, splits } = props;
     if (this.state.promptWorkflowAssignmentDialog) {
-      WorkflowAssignmentDialog.start({ splits: props.splits, project: props.project }).then(() =>
+      WorkflowAssignmentDialog.start({ splits, project }).then(() =>
         this.setState({ promptWorkflowAssignmentDialog: false })
       ).then(() => {
-        if (props.preferences.preferences.selected_workflow !== props.preferences.settings.workflow_id) {
-          props.preferences.update({ 'preferences.selected_workflow': props.preferences.settings.workflow_id });
-          props.preferences.save();
+        if (preferences.preferences.selected_workflow !== preferences.settings.workflow_id) {
+          preferences.update({ 'preferences.selected_workflow': preferences.settings.workflow_id });
+          preferences.save();
+          apiClient
+            .type('workflows')
+            .get(preferences.settings.workflow_id, {})
+            .then((newWorkflow) => {
+              actions.classifier.setWorkflow(newWorkflow);
+            });
         }
       });
     }
diff --git a/app/pages/project/home/home-workflow-button.jsx b/app/pages/project/home/home-workflow-button.jsx
index 26b08ef7..fa0f0175 100644
--- a/app/pages/project/home/home-workflow-button.jsx
+++ b/app/pages/project/home/home-workflow-button.jsx
@@ -3,8 +3,12 @@ import React from 'react';
 import { Link } from 'react-router';
 import classnames from 'classnames';
 import Translate from 'react-translate-component';
+import apiClient from 'panoptes-client/lib/api-client';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import * as classifierActions from '../../../redux/ducks/classify';
 
-export default class ProjectHomeWorkflowButton extends React.Component {
+class ProjectHomeWorkflowButton extends React.Component {
   constructor(props) {
     super(props);
 
@@ -12,10 +16,20 @@ export default class ProjectHomeWorkflowButton extends React.Component {
   }
 
   handleWorkflowSelection(e) {
-    if (this.props.disabled) {
+    const { actions, disabled, preferences, user, workflow } = this.props;
+    if (disabled) {
       e.preventDefault();
     } else {
-      this.props.onChangePreferences('preferences.selected_workflow', this.props.workflow.id);
+      actions.classifier.setWorkflow(null);
+      return apiClient
+        .type('workflows')
+        .get(workflow.id, {})
+        .then((newWorkflow) => {
+          if (user) {
+            preferences.update({ 'preferences.selected_workflow': newWorkflow.id }).save();
+          }
+          actions.classifier.setWorkflow(newWorkflow);
+        });
     }
   }
 
@@ -57,15 +71,22 @@ export default class ProjectHomeWorkflowButton extends React.Component {
 
 ProjectHomeWorkflowButton.defaultProps = {
   disabled: false,
-  onChangePreferences: () => {},
+  preferences: {},
   project: {},
   workflow: {},
   workflowAssignment: false
 };
 
 ProjectHomeWorkflowButton.propTypes = {
+  actions: PropTypes.shape({
+    classifier: PropTypes.shape({
+      setWorkflow: PropTypes.func
+    })
+  }),
   disabled: PropTypes.bool,
-  onChangePreferences: PropTypes.func.isRequired,
+  preferences: PropTypes.shape({
+    update: PropTypes.func
+  }),
   project: PropTypes.shape({
     slug: PropTypes.string
   }).isRequired,
@@ -77,4 +98,16 @@ ProjectHomeWorkflowButton.propTypes = {
     id: PropTypes.string
   }).isRequired,
   workflowAssignment: PropTypes.bool
-};
\ No newline at end of file
+};
+
+const mapStateToProps = state => ({});
+
+const mapDispatchToProps = dispatch => ({
+  actions: {
+    classifier: bindActionCreators(classifierActions, dispatch)
+  }
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ProjectHomeWorkflowButton);
+export { ProjectHomeWorkflowButton };
+
diff --git a/app/pages/project/home/home-workflow-button.spec.js b/app/pages/project/home/home-workflow-button.spec.js
index 8cc695b2..efab83bc 100644
--- a/app/pages/project/home/home-workflow-button.spec.js
+++ b/app/pages/project/home/home-workflow-button.spec.js
@@ -2,7 +2,9 @@ import React from 'react';
 import assert from 'assert';
 import { shallow } from 'enzyme';
 import sinon from 'sinon';
-import ProjectHomeWorkflowButton from './home-workflow-button';
+import apiClient from 'panoptes-client/lib/api-client';
+import { ProjectHomeWorkflowButton } from './home-workflow-button';
+import mockPanoptesResource from '../../../../test/mock-panoptes-resource';
 
 const testWorkflowWithLevel = {
   id: '2342',
@@ -20,25 +22,45 @@ const testProject = {
   slug: 'zooniverse/project'
 };
 
+const preferences = mockPanoptesResource('project-preferences', {});
+
+const actions = {
+  classifier: {
+    setWorkflow: sinon.stub()
+  }
+};
+
+const user = mockPanoptesResource('user', {});
+
 describe('ProjectHomeWorkflowButton', function() {
   let wrapper;
   let handleWorkflowSelectionSpy;
-  let onChangePreferencesSpy;
   before(function() {
     handleWorkflowSelectionSpy = sinon.spy(ProjectHomeWorkflowButton.prototype, 'handleWorkflowSelection');
-    onChangePreferencesSpy = sinon.spy();
+    sinon.stub(apiClient, 'type').callsFake(() => {
+      return {
+        get: () => Promise.resolve(testWorkflowWithoutLevel)
+      }
+    });
     wrapper = shallow(
       <ProjectHomeWorkflowButton
+        actions={actions}
         disabled={false}
-        onChangePreferences={onChangePreferencesSpy}
+        preferences={preferences}
         project={testProject}
+        user={user}
         workflow={testWorkflowWithoutLevel}
         workflowAssignment={false}
       />
     );
   });
+  afterEach(function () {
+    actions.classifier.setWorkflow.resetHistory();
+    preferences.update.resetHistory();
+  });
   after(function () {
     handleWorkflowSelectionSpy.restore();
+    apiClient.type.restore();
   });
 
   it('renders without crashing', function () {});
@@ -55,9 +77,29 @@ describe('ProjectHomeWorkflowButton', function() {
   it('calls handleWorkflowSelection onClick', function() {
     wrapper.find('Link').simulate('click');
     assert.equal(handleWorkflowSelectionSpy.calledOnce, true);
-    assert.equal(onChangePreferencesSpy.calledOnce, true);
   });
 
+  it('should update user preferences on workflow selection', function (done) {
+    wrapper.instance().handleWorkflowSelection()
+    .then(function () {
+      assert.equal(preferences.update.calledOnce, true);
+    })
+    .then(done, done);
+  });
+  it('should clear the current workflow', function (done) {
+    wrapper.instance().handleWorkflowSelection()
+    .then(function () {
+      assert.equal(actions.classifier.setWorkflow.firstCall.calledWith(null), true);
+    })
+    .then(done, done);
+  });
+  it('should select a new workflow', function (done) {
+    wrapper.instance().handleWorkflowSelection()
+    .then(function () {
+      assert.equal(actions.classifier.setWorkflow.secondCall.calledWith(testWorkflowWithoutLevel), true);
+    })
+    .then(done, done);
+  });
   it('uses the project slug in the Link href', function() {
     assert.equal(wrapper.find('Link').props().to.includes(testProject.slug), true);
   });
@@ -67,7 +109,7 @@ describe('ProjectHomeWorkflowButton', function() {
       wrapper = shallow(
         <ProjectHomeWorkflowButton
           disabled={true}
-          onChangePreferences={onChangePreferencesSpy}
+          preferences={preferences}
           project={testProject}
           workflow={testWorkflowWithoutLevel}
           workflowAssignment={false}
@@ -90,7 +132,7 @@ describe('ProjectHomeWorkflowButton', function() {
       wrapper = shallow(
         <ProjectHomeWorkflowButton
           disabled={false}
-          onChangePreferences={onChangePreferencesSpy}
+          preferences={preferences}
           project={testProject}
           workflow={testWorkflowWithoutLevel}
           workflowAssignment={true}
diff --git a/app/pages/project/home/home-workflow-buttons.jsx b/app/pages/project/home/home-workflow-buttons.jsx
index 96233f92..d6f7b30b 100644
--- a/app/pages/project/home/home-workflow-buttons.jsx
+++ b/app/pages/project/home/home-workflow-buttons.jsx
@@ -31,7 +31,7 @@ export default class ProjectHomeWorkflowButtons extends React.Component {
 
     return (
       <div className="project-home-workflow-buttons">
-        {this.props.showWorkflowButtons && this.props.activeWorkflows.length > 0 && this.props.preferences &&
+        {this.props.showWorkflowButtons && this.props.activeWorkflows.length > 0 &&
           (<div className="project-home-page__container project-home-workflow-buttons__workflow-choice-container">
             <div className="project-home-page__content">
               <h3 className="workflow-choice-container__call-to-action">
@@ -46,8 +46,9 @@ export default class ProjectHomeWorkflowButtons extends React.Component {
                   <ProjectHomeWorkflowButton
                     key={workflow.id}
                     disabled={this.shouldWorkflowBeDisabled(workflow)}
-                    onChangePreferences={this.props.onChangePreferences}
+                    preferences={this.props.preferences}
                     project={this.props.project}
+                    user={this.props.user}
                     workflow={workflow}
                     workflowAssignment={this.props.workflowAssignment}
                   />);
@@ -64,7 +65,6 @@ export default class ProjectHomeWorkflowButtons extends React.Component {
 
 ProjectHomeWorkflowButtons.defaultProps = {
   activeWorkflows: [],
-  onChangePreferences: () => {},
   preferences: {},
   project: {},
   showWorkflowButtons: false,
@@ -75,7 +75,6 @@ ProjectHomeWorkflowButtons.defaultProps = {
 
 ProjectHomeWorkflowButtons.propTypes = {
   activeWorkflows: PropTypes.arrayOf(PropTypes.object).isRequired,
-  onChangePreferences: PropTypes.func.isRequired,
   preferences: PropTypes.shape({
     preferences: PropTypes.object,
     settings: PropTypes.objectOf(PropTypes.string)
diff --git a/app/pages/project/home/home-workflow-buttons.spec.js b/app/pages/project/home/home-workflow-buttons.spec.js
index f1f152aa..3f39dcc7 100644
--- a/app/pages/project/home/home-workflow-buttons.spec.js
+++ b/app/pages/project/home/home-workflow-buttons.spec.js
@@ -2,6 +2,7 @@ import React from 'react';
 import assert from 'assert';
 import { shallow } from 'enzyme';
 import ProjectHomeWorkflowButtons from './home-workflow-buttons';
+import ProjectHomeWorkflowButton from './home-workflow-button';
 
 
 const testWorkflows = [
@@ -41,13 +42,13 @@ describe('ProjectHomeWorkflowButtons', function() {
     });
 
     it('should render active workflow button options that have a level', function() {
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').length, 3);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).length, 3);
     });
 
     it('should disable the button levels that user has not reached', function() {
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').first().props().disabled, false);
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').at(1).props().disabled, false);
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').last().props().disabled, true);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).first().props().disabled, false);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).at(1).props().disabled, false);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).last().props().disabled, true);
     });
   });
 
@@ -56,7 +57,7 @@ describe('ProjectHomeWorkflowButtons', function() {
       wrapper = shallow(
         <ProjectHomeWorkflowButtons activeWorkflows={testWorkflows} showWorkflowButtons={true} />
       );
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').length, 3);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).length, 3);
     });
   });
 
@@ -68,7 +69,7 @@ describe('ProjectHomeWorkflowButtons', function() {
     });
 
     it('should not render the workflow buttons', function() {
-      assert.equal(wrapper.find('ProjectHomeWorkflowButton').length, 0);
+      assert.equal(wrapper.find(ProjectHomeWorkflowButton).length, 0);
     });
   });
 });
diff --git a/app/pages/project/home/index.jsx b/app/pages/project/home/index.jsx
index d84bea98..57de3e79 100644
--- a/app/pages/project/home/index.jsx
+++ b/app/pages/project/home/index.jsx
@@ -95,7 +95,6 @@ export default class ProjectHomeContainer extends React.Component {
       <ProjectHomePage
         activeWorkflows={this.state.activeWorkflows}
         background={this.props.background}
-        onChangePreferences={this.props.onChangePreferences}
         organization={this.props.organization}
         preferences={this.props.preferences}
         project={this.props.project}
@@ -118,7 +117,6 @@ ProjectHomeContainer.defaultProps = {
   background: {
     src: ''
   },
-  onChangePreferences: () => {},
   organization: null,
   preferences: {},
   project: {},
@@ -142,7 +140,6 @@ ProjectHomeContainer.propTypes = {
   background: PropTypes.shape({
     src: PropTypes.string
   }),
-  onChangePreferences: PropTypes.func.isRequired,
   organization: PropTypes.shape({
     display_name: PropTypes.string,
     slug: PropTypes.string
diff --git a/app/pages/project/home/project-home.jsx b/app/pages/project/home/project-home.jsx
index 1340a79e..1ab14020 100644
--- a/app/pages/project/home/project-home.jsx
+++ b/app/pages/project/home/project-home.jsx
@@ -84,7 +84,6 @@ const ProjectHomePage = (props) => {
         <VisibilitySplit splits={props.splits} splitKey='workflow.assignment' elementKey='div'>
           <ProjectHomeWorkflowButtons
             activeWorkflows={props.activeWorkflows}
-            onChangePreferences={props.onChangePreferences}
             preferences={props.preferences}
             project={props.project}
             projectIsComplete={props.projectIsComplete}
@@ -176,7 +175,6 @@ ProjectHomePage.contextTypes = {
 
 ProjectHomePage.defaultProps = {
   activeWorkflows: [],
-  onChangePreferences: () => {},
   organization: null,
   preferences: {},
   project: {},
@@ -192,7 +190,6 @@ ProjectHomePage.propTypes = {
   background: PropTypes.shape({
     src: PropTypes.string
   }).isRequired,
-  onChangePreferences: PropTypes.func.isRequired,
   organization: PropTypes.shape({
     display_name: PropTypes.string,
     slug: PropTypes.string
diff --git a/app/pages/project/index.jsx b/app/pages/project/index.jsx
index 9273a8ca..0cebe86b 100644
--- a/app/pages/project/index.jsx
+++ b/app/pages/project/index.jsx
@@ -140,7 +140,6 @@ class ProjectPageController extends React.Component {
   }
 
   getUserProjectPreferences(project, user) {
-    this.listenToProjectPreferences(null);
 
     const userPreferences = user ?
       user.get('project_preferences', { project_id: project.id })
@@ -159,18 +158,9 @@ class ProjectPageController extends React.Component {
             }));
         })
     :
-      Promise.resolve(apiClient.type('project_preferences').create({
-        id: 'GUEST_PREFERENCES_DO_NOT_SAVE',
-        links: {
-          project: project.id
-        },
-        preferences: {}}));
+      Promise.resolve(null);
 
     return userPreferences
-      .then((projectPreferences) => {
-        this.listenToProjectPreferences(projectPreferences);
-        return projectPreferences;
-      })
       .catch((error) => {
         console.warn(error.message);
       });
@@ -284,13 +274,11 @@ class ProjectPageController extends React.Component {
   }
 
   requestUserProjectPreferences(project, user) {
-    this.listenToProjectPreferences(null);
 
     if (user) {
       return user.get('project_preferences', { project_id: project.id })
         .then(([projectPreferences]) => {
           this.setState({ projectPreferences });
-          this.listenToProjectPreferences(projectPreferences);
         })
         .catch((error) => {
           console.warn(error.message);
@@ -300,16 +288,6 @@ class ProjectPageController extends React.Component {
     }
   }
 
-  listenToProjectPreferences(projectPreferences) {
-    if (this._listenedToProjectPreferences) {
-      this._listenedToProjectPreferences.stopListening('change', this._boundForceUpdate);
-    }
-    if (projectPreferences) {
-      projectPreferences.listen('change', this._boundForceUpdate);
-    }
-    this._listenedToProjectPreferences = projectPreferences;
-  }
-
   loadFieldGuide(projectId) {
     return apiClient.type('field_guides').get({ project_id: projectId })
     .then(([guide]) => {
@@ -397,7 +375,6 @@ class ProjectPageController extends React.Component {
               projectRoles={this.state.projectRoles}
               translations={this.props.translations}
               user={this.props.user}
-              onChangePreferences={this.handleProjectPreferencesChange.bind(this)}
             >
               <ProjectPage
                 {...this.props}
@@ -405,7 +382,6 @@ class ProjectPageController extends React.Component {
                 guide={this.state.guide}
                 guideIcons={this.state.guideIcons}
                 loading={this.state.loading}
-                onChangePreferences={this.handleProjectPreferencesChange.bind(this)}
                 organization={this.state.organization}
                 owner={this.state.owner}
                 pages={this.state.pages}
diff --git a/app/pages/project/workflow-selection.jsx b/app/pages/project/workflow-selection.jsx
index f3beae68..8937bfbc 100644
--- a/app/pages/project/workflow-selection.jsx
+++ b/app/pages/project/workflow-selection.jsx
@@ -177,7 +177,9 @@ class WorkflowSelection extends React.Component {
   }
 
   handlePreferencesChange(key, value) {
-    this.props.onChangePreferences(key, value);
+    if (this.props.user) {
+      this.props.preferences.update({ [key]: value }).save();
+    }
   }
 
   workflowSelectionErrorHandler() {
@@ -207,7 +209,6 @@ WorkflowSelection.defaultProps = {
   location: {
     query: {}
   },
-  onChangePreferences() { return null; },
   preferences: {},
   projectRoles: [],
   translation: {
@@ -233,7 +234,6 @@ WorkflowSelection.propTypes = {
       workflow: PropTypes.string
     })
   }),
-  onChangePreferences: PropTypes.func,
   preferences: PropTypes.shape({
     save: PropTypes.func,
     update: PropTypes.func
diff --git a/package-lock.json b/package-lock.json
index d5cb2a3f..e73183db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7346,13 +7346,14 @@
       "dev": true
     },
     "history": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/history/-/history-3.0.0.tgz",
-      "integrity": "sha1-As/05vadxi3YEWEQSmP1uF6tDIU=",
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/history/-/history-3.3.0.tgz",
+      "integrity": "sha1-/O3M6PEpdTcVRdc1RhAzV5ptrpw=",
       "requires": {
-        "invariant": "^2.0.0",
-        "query-string": "^4.1.0",
-        "warning": "^2.0.0"
+        "invariant": "^2.2.1",
+        "loose-envify": "^1.2.0",
+        "query-string": "^4.2.2",
+        "warning": "^3.0.0"
       }
     },
     "hmac-drbg": {
@@ -8831,9 +8832,9 @@
       "integrity": "sha1-y5yf+RpSVawI8/09YyhuFd8KH8M="
     },
     "markdown-it-table-of-contents": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.4.0.tgz",
-      "integrity": "sha512-LNLrEO0WfpQ3Kbmqh9FqcnEzEbGl11lH48lpRtWn2vkSkDjDLCSVvklIuib2oj580O7jdRq0ISQr2636LaEfPw=="
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/markdown-it-table-of-contents/-/markdown-it-table-of-contents-0.4.3.tgz",
+      "integrity": "sha512-x/OdaRzLYxAjmB+jIVlXuE3nX7tZTLDQxm58RkgjTLyQ+I290jYQvPS9cJjVN6SM3U6K6CHKYNgUtPNZmLblYQ=="
     },
     "markdown-it-video": {
       "version": "0.4.0",
@@ -8841,9 +8842,9 @@
       "integrity": "sha1-H1GjRxRWolCaOCDc7ZrRzpYb22Q="
     },
     "markdownz": {
-      "version": "7.6.3",
-      "resolved": "https://registry.npmjs.org/markdownz/-/markdownz-7.6.3.tgz",
-      "integrity": "sha512-rra0c8SV/RppWMZj9qbVUKUpz3XoG4VTEkV04uOyMs5NHqyuBEJ2cpkdLZ7COfp0tgeu8HEdetBO4GhagG05dQ==",
+      "version": "7.6.4",
+      "resolved": "https://registry.npmjs.org/markdownz/-/markdownz-7.6.4.tgz",
+      "integrity": "sha512-gc0i2vAEDyL7LzMpFbx0+tx7PwsRUck3NrJ3ZCIU2zkKxkdUiwjH3i3MaJ0ekipJLRkjN3ddPtGXqPtDBhPRhg==",
       "requires": {
         "markdown-it": "~8.4.1",
         "markdown-it-anchor": "~5.0.2",
@@ -9015,9 +9016,9 @@
       "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
     },
     "mime-db": {
-      "version": "1.35.0",
-      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.35.0.tgz",
-      "integrity": "sha512-JWT/IcCTsB0Io3AhWUMjRqucrHSPsSf2xKLaRldJVULioggvkJvggZ3VXNNSRkCddE6D+BUI4HEIZIA2OjwIvg=="
+      "version": "1.37.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz",
+      "integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg=="
     },
     "mime-types": {
       "version": "2.1.18",
@@ -13968,7 +13969,7 @@
     },
     "twemoji": {
       "version": "1.4.1",
-      "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-1.4.1.tgz",
+      "resolved": "http://registry.npmjs.org/twemoji/-/twemoji-1.4.1.tgz",
       "integrity": "sha1-CEfVf+/t/3Kxv9v+4G+CJRBmNHE="
     },
     "type-check": {
@@ -14455,9 +14456,9 @@
       }
     },
     "warning": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/warning/-/warning-2.1.0.tgz",
-      "integrity": "sha1-ISINnGOvx3qMkhEeARr3Bc4MaQE=",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
+      "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
       "requires": {
         "loose-envify": "^1.0.0"
       }
diff --git a/package.json b/package.json
index 801e5fd2..dce4afa4 100644
--- a/package.json
+++ b/package.json
@@ -12,10 +12,10 @@
     "drag-reorderable": "~0.2.0",
     "engine.io-client": "~3.2.1",
     "hash.js": "~1.1.5",
-    "history": "~3.0.0",
+    "history": "~3.3.0",
     "json-api-client": "~4.0.0",
     "lodash": "~4.17.11",
-    "markdownz": "~7.6.3",
+    "markdownz": "~7.6.4",
     "mocha": "~5.2.0",
     "modal-form": "~2.7",
     "moment": "~2.22.2",


Test patch:
diff --git a/app/classifier/drawing-tools/generic-tests.spec.js b/app/classifier/drawing-tools/generic-tests.spec.js
new file mode 100644
index 00000000..0185ba0c
--- /dev/null
+++ b/app/classifier/drawing-tools/generic-tests.spec.js
@@ -0,0 +1,132 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { expect } from 'chai';
+import drawingTools from '../drawing-tools';
+
+const x = 200;
+const y = 400;
+const cursors = [
+  {
+    x: 300,
+    y: 400
+  },
+  {
+    x: 250,
+    y: 450
+  },
+  {
+    x: 200,
+    y: 500
+  }
+];
+
+const props = {
+  color: 'red',
+  containerRect: {
+    height: 100,
+    width: 100
+  },
+  naturalHeight: 100,
+  naturalWidth: 100,
+  preferences: {
+    preferences: {}
+  },
+  scale: {
+    horizontal: 1,
+    vertical: 1
+  },
+  task: {
+    tools: [{ offsetX: 50, offsetY: 50 }]
+  }
+};
+
+for (const toolType in drawingTools) {
+  let wrapper;
+  const TaskComponent = drawingTools[toolType];
+
+  describe(`Task  ${toolType}`, function() {
+    let mark;
+
+    before(function () {
+      mark = TaskComponent.defaultValues({ x, y }, props);
+      mark.tool = 0;
+    });
+
+    it('should make changes with initStart', function() {
+      const initialMark = Object.assign({}, mark);
+      if (TaskComponent.initStart) {
+        const initStartResult = TaskComponent.initStart(cursors[0], mark);
+        expect(initialMark).to.not.equal(mark);
+        expect(initStartResult).to.be.an('object');
+      }
+    });
+
+    it ('should make changes with initMove', function() {
+      const initialMark = Object.assign({}, mark);
+      if (TaskComponent.initMove) {
+        TaskComponent.initMove(cursors[1], mark);
+        expect(initialMark).to.not.equal(mark);
+      }
+    });
+
+    it ('should run initRelease', function() {
+      const initialMark = Object.assign({}, mark);
+      if (TaskComponent.initRelease) {
+        const initReleaseResult = TaskComponent.initRelease(cursors[2], mark);
+        expect(initialMark).to.not.equal(mark);
+        expect(initReleaseResult).to.be.an('object');
+      }
+    });
+
+    it ('should return a boolean with initValid', function() {
+      if (TaskComponent.initValid) {
+        const isValid = TaskComponent.initValid(mark, props);
+        expect(isValid).to.be.a('boolean');
+      }
+    });
+
+    it ('should run getDistance', function() {
+      if (TaskComponent.getDistance) {
+        const distance = TaskComponent.getDistance(cursors[0].x, cursors[0].y, cursors[1].x, cursors[1].y);
+        expect(Math.round(distance)).to.equal(71);
+      }
+    });
+
+    it ('should correctly calculate getAngle', function() {
+      if (TaskComponent.getAngle) {
+        const angle = TaskComponent.getAngle(cursors[0].x, cursors[0].y, cursors[1].x, cursors[1].y);
+        expect(Math.abs(angle)).to.equal(135);
+      }
+    });
+
+    it ('should successfuly check for completed mark', function() {
+      if (TaskComponent.forceComplete) {
+        TaskComponent.forceComplete(mark);
+      }
+
+      if (TaskComponent.isComplete) {
+        const isComplete = TaskComponent.isComplete(mark);
+        expect(isComplete).to.be.true;
+      }
+    });
+  });
+
+  describe('rendered component', function () {
+    const mark = TaskComponent.defaultValues({ x, y }, props);
+
+    if (TaskComponent.initStart) {
+      TaskComponent.initStart({ x, y }, mark);
+    }
+
+    before(function () {
+      wrapper = shallow(
+        <TaskComponent {...props} mark={mark} />
+      );
+    });
+
+    it('should render correctly', function () {
+      expect(wrapper).to.be.ok;
+      expect(wrapper.instance()).to.be.an.instanceof(TaskComponent);
+    });
+  });
+}