Skip to content

Commit

Permalink
Merge pull request #549 from zooniverse/add-feedback-ui
Browse files Browse the repository at this point in the history
Add feedback modal
  • Loading branch information
mcbouslog authored Mar 20, 2019
2 parents 2e964d9 + aeaefc7 commit 405f797
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react'
import PropTypes from 'prop-types'
import { inject, observer } from 'mobx-react'
import { Button, Box } from 'grommet'
import { Modal } from '@zooniverse/react-components'
import counterpart from 'counterpart'
import en from './locales/en'

import SubjectViewer from '../SubjectViewer'

counterpart.registerTranslations('en', en)

function storeMapper (stores) {
const {
hideFeedback,
hideSubjectViewer,
messages,
showModal
} = stores.classifierStore.feedback
return {
hideFeedback,
hideSubjectViewer,
messages,
showModal
}
}

@inject(storeMapper)
@observer
class FeedbackModal extends React.Component {
render () {
const label = counterpart('FeedbackModal.label')
const { hideFeedback, hideSubjectViewer, messages, showModal } = this.props

if (showModal) {
return (
<Modal
active={showModal}
closeFn={hideFeedback}
title={label}
>
<>
<Box
height='medium'
overflow='auto'
>
{!hideSubjectViewer && <SubjectViewer />}
<ul>
{messages.map(message =>
<li key={Math.random()}>
{message}
</li>
)}
</ul>
</Box>
<Box pad={{ top: 'small' }}>
<Button
onClick={hideFeedback}
label={counterpart('FeedbackModal.close')}
primary
/>
</Box>
</>
</Modal>
)
}

return null
}
}

FeedbackModal.wrappedComponent.propTypes = {
hideFeedback: PropTypes.func,
messages: PropTypes.arrayOf(PropTypes.string),
showModal: PropTypes.bool
}

export default FeedbackModal
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react'
import { shallow } from 'enzyme'
import { expect } from 'chai'
import sinon from 'sinon'
import { Button } from 'grommet'
import FeedbackModal from './FeedbackModal'

describe('FeedbackModal', function () {
it('should render without crashing', function () {
const wrapper = shallow(<FeedbackModal.wrappedComponent />)
expect(wrapper).to.be.ok
})

it('should not render if showModal false', function () {
const wrapper = shallow(<FeedbackModal.wrappedComponent showModal={false} />)
expect(wrapper.html()).to.be.null
})

it('should show messages', function () {
const wrapper = shallow(<FeedbackModal.wrappedComponent showModal messages={['Yay!', 'Good Job', 'Try Again']} />)
const list = wrapper.find('li')
expect(list).to.have.lengthOf(3)
})

it('should call hideFeedback on close', function () {
const hideFeedbackStub = sinon.stub()
const wrapper = shallow(<FeedbackModal.wrappedComponent showModal messages={['Yay!', 'Good Job', 'Try Again']} hideFeedback={hideFeedbackStub} />)
wrapper.find(Button).simulate('click')
expect(hideFeedbackStub).to.have.been.called
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './FeedbackModal'
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"FeedbackModal": {
"close": "Close",
"label": "Feedback"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import styled from 'styled-components'
import { pxToRem } from '@zooniverse/react-components'

import FeedbackModal from '../../../Feedback'
import ImageToolbar from '../../../ImageToolbar'
import MetaTools from '../../../MetaTools'
import SubjectViewer from '../../../SubjectViewer'
Expand Down Expand Up @@ -56,6 +57,7 @@ function DefaultLayout () {
<StyledMetaTools />
</ViewerGrid>
<StyledTaskArea />
<FeedbackModal />
</ContainerGrid>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import TaskHelp from './components/TaskHelp'
import { default as TaskNavButtons } from './components/TaskNavButtons'

function storeMapper (stores) {
// TODO remove feedback store, added so FeedbackStore afterAttach would run during initial store development
const { isActive } = stores.classifierStore.feedback
const { loadingState } = stores.classifierStore.workflows
const { active: step } = stores.classifierStore.workflowSteps
const tasks = stores.classifierStore.workflowSteps.activeStepTasks
Expand Down
97 changes: 80 additions & 17 deletions packages/lib-classifier/src/store/FeedbackStore.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,94 @@
import { autorun } from 'mobx'
import { addDisposer, getRoot, onAction, types } from 'mobx-state-tree'
import { addDisposer, addMiddleware, getRoot, onAction, types } from 'mobx-state-tree'
import { flatten } from 'lodash'

import helpers from './feedback/helpers'
import strategies from './feedback/strategies'

const FeedbackStore = types
.model('FeedbackStore', {
isActive: types.optional(types.boolean, false),
rules: types.map(types.frozen({}))
rules: types.map(types.frozen({})),
showModal: types.optional(types.boolean, false)
})
.volatile(self => ({
onHide: () => true
}))
.views(self => ({
get hideSubjectViewer () {
return flatten(Array.from(self.rules.values()))
.some(rule => rule.hideSubjectViewer)
},
get messages () {
return flatten(Array.from(self.rules.values()))
.map(rule => {
if (rule.success && rule.successEnabled) {
return rule.successMessage
} else if (!rule.success && rule.failureEnabled) {
return rule.failureMessage
}
}).filter(Boolean)
}
}))
.actions(self => {
function afterAttach () {
createSubjectObserver()
createClassificationObserver()
function setOnHide (onHide) {
self.onHide = onHide
}

function createSubjectObserver () {
const subjectDisposer = autorun(() => {
const subject = getRoot(self).subjects.active
if (subject) {
self.reset()
self.createRules(subject)
}
})
addDisposer(self, subjectDisposer)
function afterAttach () {
createClassificationObserver()
createSubjectMiddleware()
createSubjectObserver()
}

function createClassificationObserver () {
const classificationDisposer = autorun(() => {
onAction(getRoot(self).classifications, (call) => {
if (call.name === 'completeClassification') {
const annotations = getRoot(self).classifications.currentAnnotations
for (const value of annotations.values()) {
self.update(value)
}
annotations.forEach(annotation => self.update(annotation))
}
})
})
addDisposer(self, classificationDisposer)
}

function onSubjectAdvance (call, next, abort) {
const shouldShowFeedback = self.isActive && self.messages.length && !self.showModal
if (shouldShowFeedback) {
abort()
const onHide = getRoot(self).subjects.advance
self.setOnHide(onHide)
self.showFeedback()
} else {
next(call)
}
}

function createSubjectMiddleware () {
const subjectMiddleware = autorun(() => {
addMiddleware(getRoot(self).subjects, (call, next, abort) => {
if (call.name === 'advance') {
onSubjectAdvance(call, next, abort)
} else {
next(call)
}
})
})
addDisposer(self, subjectMiddleware)
}

function createSubjectObserver () {
const subjectDisposer = autorun(() => {
const subject = getRoot(self).subjects.active
if (subject) {
self.reset()
self.createRules(subject)
}
})
addDisposer(self, subjectDisposer)
}

function createRules (subject) {
const project = getRoot(self).projects.active
const workflow = getRoot(self).workflows.active
Expand All @@ -51,6 +100,15 @@ const FeedbackStore = types
}
}

function showFeedback () {
self.showModal = true
}

function hideFeedback () {
self.onHide()
self.showModal = false
}

function update (annotation) {
const { task, value } = annotation
const taskRules = self.rules.get(task) || []
Expand All @@ -64,11 +122,16 @@ const FeedbackStore = types
function reset () {
self.isActive = false
self.rules.clear()
self.showModal = false
}

return {
afterAttach,
createRules,
setOnHide,
showFeedback,
hideFeedback,
onSubjectAdvance,
update,
reset
}
Expand Down
Loading

0 comments on commit 405f797

Please sign in to comment.