Skip to content

Commit

Permalink
Field guide modal (#637)
Browse files Browse the repository at this point in the history
* Initial button refactor

* Set the label correctly

* Reorganize and start field guide modal

* Try out grid

* Basic FieldGuide UI

* Restructure components. Scaffold tests

* Fix locale. Add tests for FieldGuideItem

* Fix FieldGuideItem specs. Add FieldGuideContainer specs

* Add tests for FieldGuideItemButton

* Add tests for FieldGuideItems

* Add tests for FieldGuide, FieldGuideButton, HelpIcon. Fix store tests.

* Tweak the hover state styles and modal width.

* Apply suggestions from code review

Co-Authored-By: srallen <srallen@users.noreply.github.com>

* Use an anchor instead of a button for field guide items list

* Rename activeItem and associated action to activeItemIndex

* Update locale

* Fix connected component to use renamed action

* Fix renamings. Support setting pad on Modal body. Use CSS grid correctly

* Update tests

* Be clear about what actions are doing. Set href on anchor.

* Fix bug with setting active item index compared to items list length

* Remove leftover only
  • Loading branch information
srallen authored and eatyourgreens committed Apr 8, 2019
1 parent bd0f331 commit 59ac933
Show file tree
Hide file tree
Showing 35 changed files with 929 additions and 140 deletions.
@@ -0,0 +1,58 @@
import { Modal } from '@zooniverse/react-components'
import counterpart from 'counterpart'
import React from 'react'
import PropTypes from 'prop-types'
import { inject, observer } from 'mobx-react'
import FieldGuideButton from './components/FieldGuideButton'
import FieldGuide from './components/FieldGuide'
import en from './locales/en'

counterpart.registerTranslations('en', en)

function storeMapper(stores) {
const { setModalVisibility, showModal } = stores.classifierStore.fieldGuide
return {
setModalVisibility,
showModal
}
}

@inject(storeMapper)
@observer
class FieldGuideContainer extends React.Component {
onClose () {
const { setModalVisibility } = this.props
setModalVisibility(false)
}

render () {
const {
showModal
} = this.props

return (
<>
<FieldGuideButton />
<Modal
active={showModal}
closeFn={this.onClose.bind(this)}
pad='small'
title={counterpart('FieldGuide.title')}
>
<FieldGuide />
</Modal>
</>
)
}
}

FieldGuideContainer.wrappedComponent.defaultProps = {
showModal: false
}

FieldGuideContainer.wrappedComponent.propTypes = {
setModalVisibility: PropTypes.func.isRequired,
showModal: PropTypes.bool
}

export default FieldGuideContainer
@@ -0,0 +1,26 @@
import { shallow, mount } from 'enzyme'
import React from 'react'
import sinon from 'sinon'
import { Modal } from '@zooniverse/react-components'
import FieldGuideContainer from './FieldGuideContainer'

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

it('should set the Modal active prop with the showModal prop', function () {
const wrapper = shallow(<FieldGuideContainer.wrappedComponent setModalVisibility={() => { }} showModal={false} />)
expect(wrapper.find(Modal).props().active).to.be.false
wrapper.setProps({ showModal: true })
expect(wrapper.find(Modal).props().active).to.be.true
})

it('should call setModalVisibility onClose of the modal', function () {
const setModalVisibilitySpy = sinon.spy()
const wrapper = shallow(<FieldGuideContainer.wrappedComponent setModalVisibility={setModalVisibilitySpy} showModal={true} />)
wrapper.instance().onClose()
expect(setModalVisibilitySpy).to.have.been.calledOnceWith(false)
})
})
@@ -0,0 +1,49 @@
import { Box } from 'grommet'
import React from 'react'
import PropTypes from 'prop-types'
import { ResponsiveContext } from 'grommet'
import { inject, observer } from 'mobx-react'
import FieldGuideItems from './components/FieldGuideItems'
import FieldGuideItem from './components/FieldGuideItem'

function storeMapper(stores) {
const { active: fieldGuide, activeItemIndex } = stores.classifierStore.fieldGuide
return {
activeItemIndex,
items: fieldGuide.items
}
}

@inject(storeMapper)
@observer
class FieldGuide extends React.Component {
render () {
const { activeItemIndex, className, items } = this.props
return (
<ResponsiveContext.Consumer>
{size => {
const height = (size === 'small') ? '100%' : '415px'
const width = (size === 'small') ? '100%' : '490px'
return (
<Box className={className} height={height} overflow='auto' width={width}>
{items[activeItemIndex] ?
<FieldGuideItem item={items[activeItemIndex]} /> :
<FieldGuideItems items={items} />}
</Box>)}}
</ResponsiveContext.Consumer>
)
}
}

FieldGuide.wrappedComponent.defaultProps = {
activeItemIndex: -1,
className: '',
}

FieldGuide.wrappedComponent.propTypes = {
activeItemIndex: PropTypes.number,
className: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.object).isRequired
}

export default FieldGuide
@@ -0,0 +1,51 @@
import { shallow, mount } from 'enzyme'
import React from 'react'
import FieldGuide from './FieldGuide'
import FieldGuideItem from './components/FieldGuideItem'
import FieldGuideItems from './components/FieldGuideItems'
import { FieldGuideFactory, FieldGuideMediumFactory } from '../../../../../../../test/factories'

const medium = FieldGuideMediumFactory.build()
const items = [
{
title: 'Cat',
icon: medium.id,
content: 'lorem ipsum'
},
{
title: 'Dog',
content: 'Foo bar'
},
{ title: 'Iguana', content: 'hello' },
{ title: 'Koala', content: '' },
{ title: 'Dragon', content: 'Why is this here?' }
]

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

xit('should render FieldGuideItems if there is not an active item', function () {
const wrapper = shallow(
<FieldGuide.wrappedComponent
items={items}
/>)
expect(wrapper.find(FieldGuideItems)).to.have.lengthOf(1)
expect(wrapper.find(FieldGuideItem)).to.have.lengthOf(0)
})

xit('should render FieldGuideItem if there is an active item', function () {
const wrapper = mount(
<FieldGuide.wrappedComponent
activeItemIndex={0}
items={items}
/>)
expect(wrapper.find(FieldGuideItems)).to.have.lengthOf(0)
expect(wrapper.find(FieldGuideItem)).to.have.lengthOf(1)
})
})
@@ -0,0 +1,83 @@
import { Button, Box } from 'grommet'
import { FormPrevious } from 'grommet-icons'
import styled from 'styled-components'
import React from 'react'
import { Markdownz } from '@zooniverse/react-components'
import zooTheme from '@zooniverse/grommet-theme'
import PropTypes from 'prop-types'
import { observable } from 'mobx'
import { inject, observer, PropTypes as MobXPropTypes } from 'mobx-react'
import FieldGuideItemIcon from '../FieldGuideItemIcon'
import counterpart from 'counterpart'
import en from './locales/en'

counterpart.registerTranslations('en', en)

const StyledButton = styled(Button)`
padding: 0;
&:hover > svg, &:focus > svg {
fill: ${zooTheme.global.colors['dark-5']};
stroke: ${zooTheme.global.colors['dark-5']};
}
`

const FieldGuideItemHeader = styled(Box)`
> h3 {
margin: 0;
}
`

function storeMapper(stores) {
const { setActiveItemIndex, attachedMedia: icons } = stores.classifierStore.fieldGuide
return {
icons,
setActiveItemIndex
}
}

@inject(storeMapper)
@observer
class FieldGuideItem extends React.Component {
render () {
const { className, icons, item, setActiveItemIndex } = this.props
const icon = icons.get(item.icon)

return (
<Box className={className}>
<FieldGuideItemHeader align='center' direction='row' margin={{ bottom: 'small' }}>
<StyledButton
a11yTitle={counterpart("FieldGuideItem.ariaTitle")}
icon={<FormPrevious color='light-5' />}
margin={{ right: 'small' }}
onClick={() => setActiveItemIndex()}
plain
/>
<Markdownz>
{`### ${item.title}`}
</Markdownz>
</FieldGuideItemHeader>
<Box direction='column'>
<FieldGuideItemIcon icon={icon} height='140' viewBox='0 0 200 100' />
<Markdownz>
{item.content}
</Markdownz>
</Box>
</Box>
)
}
}

FieldGuideItem.wrappedComponent.defaultProps = {
className: '',
icons: observable.map()
}

FieldGuideItem.wrappedComponent.propTypes = {
className: PropTypes.string,
icons: MobXPropTypes.observableMap,
item: PropTypes.object.isRequired,
setActiveItemIndex: PropTypes.func.isRequired
}

export default FieldGuideItem
@@ -0,0 +1,75 @@
import { shallow, mount } from 'enzyme'
import sinon from 'sinon'
import React from 'react'
import { observable } from 'mobx'
import { Button } from 'grommet'
import { Markdownz, Media } from '@zooniverse/react-components'
import FieldGuideItem from './FieldGuideItem'
import FieldGuideItemIcon from '../FieldGuideItemIcon'
import { FieldGuideMediumFactory } from '../../../../../../../../../test/factories'

const medium = FieldGuideMediumFactory.build()
const attachedMedia = observable.map()
attachedMedia.set(medium.id, medium)
const item = {
title: 'Cat',
icon: medium.id,
content: 'lorem ipsum'
}

describe('Component > FieldGuideItem', function () {
it('should render without crashing', function () {
const wrapper = shallow(
<FieldGuideItem.wrappedComponent
icons={attachedMedia}
item={item}
setActiveItemIndex={() => { }}
/>)
expect(wrapper).to.be.ok
})

it('should call setActiveItemIndex when the previous button is clicked', function () {
const setActiveItemIndexSpy = sinon.spy()
const wrapper = mount(
<FieldGuideItem.wrappedComponent
icons={attachedMedia}
item={item}
setActiveItemIndex={setActiveItemIndexSpy}
/>)
wrapper.find(Button).simulate('click')
expect(setActiveItemIndexSpy).to.have.been.calledOnceWith()
})

it('should render the item title as markdown', function () {
const wrapper = shallow(
<FieldGuideItem.wrappedComponent
icons={attachedMedia}
item={item}
setActiveItemIndex={() => {}}
/>)

expect(wrapper.find(Markdownz).first().contains(`### ${item.title}`)).to.be.true
})

it('should render the item content as markdown', function () {
const wrapper = shallow(
<FieldGuideItem.wrappedComponent
icons={attachedMedia}
item={item}
setActiveItemIndex={() => { }}
/>)

expect(wrapper.find(Markdownz).last().contains(item.content)).to.be.true
})

it('should render a FieldGuideItemIcon component for the icon', function () {
const wrapper = shallow(
<FieldGuideItem.wrappedComponent
icons={attachedMedia}
item={item}
setActiveItemIndex={() => { }}
/>)

expect(wrapper.find(FieldGuideItemIcon)).to.have.lengthOf(1)
})
})
@@ -0,0 +1 @@
export { default } from './FieldGuideItem'
@@ -0,0 +1,5 @@
{
"FieldGuideItem": {
"ariaTitle": "Go back to Field Guide items list"
}
}
@@ -0,0 +1,36 @@
import zooTheme from '@zooniverse/grommet-theme'
import React from 'react'
import { Media } from '@zooniverse/react-components'
import PropTypes from 'prop-types'

export default function FieldGuideItemIcon({ alt, className, height, icon, viewBox, width }) {
if (icon && Object.keys(icon).length > 0) {
return (
<Media alt={alt} className={className} fit='contain' height={height} src={icon.src} width={width} />
)
}

return (
<svg className={className} viewBox={viewBox}>
<rect fill={zooTheme.global.colors['accent-2']} height={height} width={width} />
</svg>
)
}

FieldGuideItemIcon.defaultProps = {
alt: '',
className: '',
height: '100%',
icon: {},
viewBox: '0 0 100 100',
width: '100%'
}

FieldGuideItemIcon.propTypes = {
alt: PropTypes.string,
className: PropTypes.string,
height: PropTypes.string,
icon: PropTypes.object,
viewBox: PropTypes.string,
width: PropTypes.string
}

0 comments on commit 59ac933

Please sign in to comment.