Commit 1020efdf authored by Jure's avatar Jure

Merge branch 'people-picker' into 'master'

feat: Add People picker from elife-xpub

See merge request !542
parents f781925d b59b8a9b
Pipeline #12767 passed with stages
in 15 minutes and 22 seconds
{
"name": "pubsweet-component-people-picker",
"version": "0.0.1",
"description": "People Picker component for pubsweet",
"main": "src/index.js",
"author": "Collaborative Knowledge Foundation",
"license": "MIT",
"dependencies": {
"@pubsweet/ui": "^10.2.0",
"@pubsweet/ui-toolkit": "^2.1.6",
"formik": "^1.3.0",
"lodash": "^4.17.11",
"prop-types": "^15.5.10",
"react-autosuggest": "^9.4.3",
"react-feather": "^1.1.6",
"recompose": "^0.30.0"
},
"peerDependencies": {
"graphql-tag": "^2.10.0",
"pubsweet-client": ">=1.0.0",
"react": ">=16",
"react-apollo": "^2.3.3",
"styled-components": "^4.1.3"
},
"repository": {
"type": "git",
"url": "https://gitlab.coko.foundation/pubsweet/pubsweet",
"path": "Signup"
},
"publishConfig": {
"access": "public"
},
"gitHead": "6b100b76f21785e5e50fca082a2743d3d0b1c88a"
}
import React from 'react'
import { withTheme } from 'styled-components'
import { get, has } from 'lodash'
import { Icon as PubSweetIcon } from '@pubsweet/ui'
// @todo remove this file and use pubsweet's Icon once it supports override via theme
const Icon = ({ iconName, overrideName, className, theme, ...props }) => {
const isOverrideInTheme = has(theme.icons, overrideName)
if (isOverrideInTheme) {
const OverrideIcon = get(theme.icons, overrideName)
return <OverrideIcon className={className} {...props} />
}
return <PubSweetIcon className={className} {...props} />
}
export default withTheme(Icon)
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { th, media } from '@pubsweet/ui-toolkit'
import SelectedItem from './SelectedItem'
import { peoplePropType } from './types'
import PersonPodGrid from './PersonPodGrid'
const SelectedItemsGrid = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
`
const SelectedContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${th('gridUnit')};
${media.tabletPortraitUp`
justify-content: space-between;
flex-direction: row;
`};
`
const StyledBox = styled.div`
margin-right: ${th('gridUnit')};
`
const SelectedItems = ({ selection, onCloseClick }) => (
<SelectedItemsGrid>
{selection.map(person => (
<StyledBox key={person.id}>
<SelectedItem
label={person.name}
onCloseClick={() => {
onCloseClick(person)
}}
/>
</StyledBox>
))}
</SelectedItemsGrid>
)
const MessageWrapper = styled.div`
${media.tabletPortraitUp`
position: absolute;
top: calc(-${th('gridUnit')} * 7);
right: calc(${th('gridUnit')} * 2);
`};
`
const SuccessMessage = styled(MessageWrapper)`
color: ${th('colorSuccess')};
`
const SelectionHint = ({ selection, minSelection, maxSelection }) => {
const selectionLength = selection.length
if (selectionLength < minSelection) {
const numRequired = minSelection - selectionLength
return (
<MessageWrapper>
{numRequired} more suggestion
{numRequired === 1 ? '' : 's'} required
</MessageWrapper>
)
}
if (selectionLength >= maxSelection) {
return <SuccessMessage>Maximum {maxSelection} choices</SuccessMessage>
}
return null
}
const PeoplePickerBody = ({
isSelected,
maxSelection,
minSelection,
people,
selection,
toggleSelection,
...props
}) => (
<React.Fragment>
<SelectedContainer>
<SelectedItems onCloseClick={toggleSelection} selection={selection} />
<SelectionHint
maxSelection={maxSelection}
minSelection={minSelection}
selection={selection}
/>
</SelectedContainer>
<PersonPodGrid
isSelected={isSelected}
maxSelection={maxSelection}
people={people}
selection={selection}
toggleSelection={toggleSelection}
/>
</React.Fragment>
)
SelectedItems.propTypes = {
selection: peoplePropType.isRequired,
onCloseClick: PropTypes.func.isRequired,
}
SelectionHint.propTypes = {
selection: peoplePropType.isRequired,
maxSelection: PropTypes.number.isRequired,
minSelection: PropTypes.number.isRequired,
}
PeoplePickerBody.propTypes = {
isSelected: PropTypes.func.isRequired,
maxSelection: PropTypes.number.isRequired,
minSelection: PropTypes.number.isRequired,
people: peoplePropType.isRequired,
selection: peoplePropType.isRequired,
toggleSelection: PropTypes.func.isRequired,
}
export default PeoplePickerBody
`Body` is the main section of the PeoplePicker (everything except the buttons). It combines SelectedItems, SelectionHint & PersonPodGrid
```js
const people = [
{
id: 1,
name: 'Annie Badger',
aff: 'A University',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 2,
name: 'Bobby Badger',
aff: 'B College',
focuses: ['cell biology'],
expertises: [
'Biochemistry and Chemical Biology',
'Chromosomes and Gene Expression',
],
},
{
id: 3,
name: 'Chastity Badger',
aff: 'C Institute',
focuses: ['computational and systems biology'],
expertises: [
'Developmental Biology',
'Stem Cells and Regenerative Medicine',
],
},
{
id: 4,
name: 'Dave Badger',
aff: 'D Research Lab',
focuses: ['auditory cognition'],
expertises: ['Neuroscience', 'Pumpkins', 'Chaffinches'],
},
]
initialState = {
selection: people.slice(0, 2),
}
;<PeoplePickerBody
isSelected={person => state.selection.some(p => p.id === person.id)}
maxSelection={5}
minSelection={3}
people={people}
selection={state.selection}
toggleSelection={person => {
if (state.selection.some(p => p.id === person.id)) {
setState({
selection: state.selection.filter(p => p.id !== person.id),
})
} else {
setState({ selection: state.selection.concat(person) })
}
}}
/>
```
import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { th, media } from '@pubsweet/ui-toolkit'
import { Button } from '@pubsweet/ui'
const FlexWrapper = styled.div`
display: flex;
${media.tabletLandscapeUp`
margin-left: 16.666%;
margin-right: 16.666%;
`};
`
const CancelButton = styled(Button)`
margin-right: calc(${th('gridUnit')} * 3);
`
const PeoplePickerButtons = ({ isValid = false, onCancel, onSubmit }) => (
<FlexWrapper>
<CancelButton onClick={onCancel}>Cancel</CancelButton>
<div>
<Button
data-test-id="people-picker-add"
disabled={!isValid}
onClick={onSubmit}
primary
>
Add
</Button>
</div>
</FlexWrapper>
)
PeoplePickerButtons.propTypes = {
isValid: PropTypes.bool,
onCancel: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
}
PeoplePickerButtons.defaultProps = {
isValid: false,
}
export default PeoplePickerButtons
Add button is disabled by default:
```js
;<PeoplePickerButtons
onCancel={() => console.log('cancelled')}
onSubmit={() => console.log('submitted')}
/>
```
The `isValid` prop can be used to make the button clickable again:
```js
;<PeoplePickerButtons
isValid={true}
onCancel={() => console.log('cancelled')}
onSubmit={() => console.log('submitted')}
/>
```
import React, { Fragment } from 'react'
import styled from 'styled-components'
import { th, media } from '@pubsweet/ui-toolkit'
import PeoplePickerLogic from './PeoplePickerLogic'
import PeoplePickerButtons from './PeoplePickerButtons'
import PeoplePickerBody from './PeoplePickerBody'
import SearchBox from './SearchBox'
const MainColumn = styled.div`
box-sizing: border-box;
flex: 1 1 100%;
min-width: 0;
position: relative;
padding-left: calc(${th('gridUnit')}* 2);
padding-right: calc(${th('gridUnit')} * 2);
width: 100%;
${media.tabletPortraitUp`
width: 50%;
`};
${media.tabletLandscapeUp`
width: 33%;
margin-left: 16.666%;
margin-right: 16.666%;
padding-left: ${th('gridUnit')};
padding-right: ${th('gridUnit')};
`};
`
const SearchBoxContainer = styled.div`
box-sizing: border-box;
display: flex;
margin-left: calc(-${th('gridUnit')} * 2);
margin-right: calc(-${th('gridUnit')} * 2);
`
const BodyContainer = styled.div`
box-sizing: border-box;
display: flex;
margin-bottom: calc(${th('gridUnit')} * 20);
margin-left: calc(-${th('gridUnit')} * 2);
margin-right: calc(-${th('gridUnit')} * 2);
`
const PeoplePickerLayout = ({
modalTitle,
inputOverrideComponent,
...props
}) => (
<Fragment>
<PeoplePickerLogic {...props}>
{innerProps => (
<React.Fragment>
<SearchBoxContainer>
<MainColumn>
<SearchBox
filterFunction={innerProps.filterFunction}
inputOverrideComponent={inputOverrideComponent}
onSubmit={innerProps.searchSubmit}
options={innerProps.searchOptions}
placeholder={props.searchBoxPlaceholder}
/>
</MainColumn>
</SearchBoxContainer>
<BodyContainer data-test-id="people-picker-body">
<MainColumn>
<PeoplePickerBody {...innerProps} />
</MainColumn>
</BodyContainer>
<PeoplePickerButtons {...innerProps} />
</React.Fragment>
)}
</PeoplePickerLogic>
</Fragment>
)
export default PeoplePickerLayout
Please note: the PeoplePicker is a combination of the `PeoplePickerLayout` (how it's rendered) & the `PeoplePickerLogic` (how the sub-components interact)
The People Picker Layout is responsible for rendering the `PeoplePickerBody`, `PeoplePickerButtons` & `SearchBox` in eLife's chosen order - search at the top, then grid, then buttons below.
### Search box behaviour within the People Picker
Upon opening the People Picker Modal, the search box is empty.
The user can search by name, affiliation or subject areas.
To update the list of peron pods the user has to press enter or click on the search icon after typing something.
Searching for an empty string returns all person pods.
Currently the input won't generate a dropdown list of suggestions (but will at some point in the future).
### Overriding the Search box:
The `PeoplePicker` supports overriding the search box with an optional custom component. A working example of this can be found in the source code, assigned to the variable `InputOverride`. The injected
component is always rendered when present, and is passed the following props:
- `onClearHandler`: a function that is called to clear the search input
- `onSearchHandler`: a function that executes the search
- `onChange` & `onKeyDown`: Updates some autocomplete stuff, should be attached to whatever input field is in the search box.
- `placeholder`: Placeholder text, defualts to _'Search string here...'_
- `value`: The string value of the search box
```js
const InputOverride = ({
onClearHandler,
onSearchHandler,
onChange,
onKeyPress,
placeholder,
value,
}) => (
<div>
<input
onChange={onChange}
onKeyPress={onKeyPress}
placeholder={placeholder}
value={value}
/>
<button onClick={onSearchHandler}>search</button>
<button onClick={onClearHandler}>clear</button>
</div>
)
const PeoplePickerBody = require('./PeoplePickerBody').default
const PeoplePickerButtons = require('./PeoplePickerButtons').default
const people = [
{
id: 1,
name: 'Annie Badger',
aff: 'A University',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 2,
name: 'Bobby Badger',
aff: 'B College',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 3,
name: 'Chastity Badger',
aff: 'C Institute',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 4,
name: 'Dave Badger',
aff: 'D Research Lab',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
]
const getPeople = searchValue => {
if (!searchValue) return people
const inputValue = searchValue.trim().toLowerCase()
if (!inputValue) return people
return people.filter(person => person.name.toLowerCase().includes(inputValue))
}
initialState = { open: false }
;<PeoplePickerLayout
inputOverrideComponent={
undefined /* InputOverride (if you want to override the input) */
}
initialSelection={[people[1]]}
minSelection={1}
maxSelection={3}
onSubmit={selection => console.log('Selected', selection)}
onCancel={() => console.log('Cancelled')}
people={getPeople}
>
{props => (
<React.Fragment>
<PeoplePickerButtons {...props} />
<hr />
<PeoplePickerBody {...props} />
</React.Fragment>
)}
</PeoplePickerLayout>
```
```js
const PeoplePickerBody = require('./PeoplePickerBody').default
const PeoplePickerButtons = require('./PeoplePickerButtons').default
// @todo put this in a separate file and import
const people = [
{
id: 1,
name: 'Annie Badger',
aff: 'A University',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 2,
name: 'Bobby Badger',
aff: 'B College',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 3,
name: 'Chastity Badger',
aff: 'C Institute',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 4,
name: 'Dave Badger',
aff: 'D Research Lab',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
]
initialState = { open: false }
;<PeoplePickerLayout
initialSelection={[people[1]]}
minSelection={1}
maxSelection={3}
onSubmit={selection => console.log('Selected', selection)}
onCancel={() => console.log('Cancelled')}
people={people}
>
{props => (
<React.Fragment>
<PeoplePickerButtons {...props} />
<hr />
<PeoplePickerBody {...props} />
</React.Fragment>
)}
</PeoplePickerLayout>
```
```js
const PeoplePickerBody = require('./PeoplePickerBody').default
const PeoplePickerButtons = require('./PeoplePickerButtons').default
// @todo put this in a separate file and import
const people = [
{
id: 1,
name: 'Annie Badger',
aff: 'A University',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 2,
name: 'Bobby Badger',
aff: 'B College',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 3,
name: 'Chastity Badger',
aff: 'C Institute',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
{
id: 4,
name: 'Dave Badger',
aff: 'D Research Lab',
focuses: ['biophysics and structural biology', 'immunology'],
expertises: ['Evolutionary Biology', 'Microbiology and Infectious Disease'],
},
]
const getPeople = (people, searchValue) => {
if (!searchValue) return people
const inputValue = searchValue.trim().toLowerCase()
if (!inputValue) return people
return people.filter(person => person.name.toLowerCase().includes(inputValue))
}
initialState = { open: false }
;<PeoplePickerLayout
initialSelection={[people[1]]}
minSelection={1}
maxSelection={3}
onSubmit={selection => console.log('Selected', selection)}
onCancel={() => console.log('Cancelled')}
people={people}
searchBoxPlaceholder="Search by name, etc."
customFilterFn={getPeople}
>
{props => (
<React.Fragment>
<PeoplePickerButtons {...props} />
<hr />
<PeoplePickerBody {...props} />
</React.Fragment>
)}
</PeoplePickerLayout>
```
/* eslint-disable react/no-unused-prop-types */
import React from 'react'
import PropTypes from 'prop-types'
import { escapeRegExp } from 'lodash'
import { peoplePropType } from './types'
function stringifyObjectValues(val) {
if (val === undefined || val === null) {
return ''
}
if (val instanceof Object && !(val instanceof Date)) {
// Arrays are also object, and keys just returns the array indexes
// Date objects we convert to strings
return Object.keys(val)
.sort()
.filter(v => v !== undefined && v !== null)
.map(k => stringifyObjectValues(val[k]))
.join(' ')
}
return String(val)
}
function localFilterFn(people, searchValue) {
return people.filter(
person =>
stringifyObjectValues(person).match(new RegExp(searchValue, 'gi')) !==
null,
)
}
class PeoplePickerLogic extends React.Component {
constructor(props) {
super(props)
this.state = {
selection: this.props.initialSelection,
searchValue: '',
}
}
toggleSelection(person) {
if (this.select(person)) {
this.setState({
selection: this.state.selection.filter(p => p.id !== person.id),
})
} else if (this.state.selection.length < this.props.maxSelection) {
this.setState({ selection: this.state.selection.concat(person) })
}
}
handleSubmit() {