Skip to content

Commit

Permalink
Extract toolbar filtering and sorting
Browse files Browse the repository at this point in the history
Before, filtering and sorting was tied to VM entities. After this patch
the generic filter/sort component can be reused for other entities.
  • Loading branch information
rszwajko committed Jun 9, 2022
1 parent d467ecf commit b52067d
Show file tree
Hide file tree
Showing 7 changed files with 393 additions and 241 deletions.
78 changes: 78 additions & 0 deletions src/components/Toolbar/DatePickerFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { withMsg } from '_/intl'
import {
DatePicker,
InputGroup,
ToolbarFilter,
} from '@patternfly/react-core'

import moment from 'moment'

const DatePickerFilter = ({
filterId,
selectedFilters,
showToolbarItem,
title,
onFilterUpdate,
msg,
}) => {
const dateFormat = moment.localeData().longDateFormat('L')
const formatDate = date => moment(date).format(dateFormat)
const parseDate = str => moment(str, dateFormat).toDate()
const isValidDate = date => moment(date, dateFormat).isValid()
const toISO = str => moment(str, dateFormat).format('YYYY-MM-DD')
const fromISO = str => moment(str, 'YYYY-MM-DD').format(dateFormat)

const [date, setDate] = useState(toISO(formatDate(Date.now())))

const clearSingleDate = (option) => {
console.warn('clearSingle ', option)
const fixed = toISO(option)
onFilterUpdate([...selectedFilters?.filter(d => d !== fixed)])
}

const onDateChange = (inputDate, newDate) => {
if (isValidDate(inputDate)) {
const fixed = toISO(inputDate)
setDate(fixed)
onFilterUpdate([...selectedFilters?.filter(d => d !== fixed), fixed])
}
}

return (
<ToolbarFilter
key={filterId}
chips={selectedFilters?.map(fromISO)}
deleteChip={(category, option) => clearSingleDate(option)}
deleteChipGroup={() => onFilterUpdate([])}
categoryName={title}
showToolbarItem={showToolbarItem}
>
<InputGroup>
<DatePicker
value={fromISO(date)}
dateFormat={formatDate }
dateParse={parseDate}
onChange={onDateChange}
aria-label={msg.date()}
buttonAriaLabel={msg.toggleDatePicker()}
placeholder={dateFormat}
invalidFormatText={msg.invalidDateFormat({ format: dateFormat })}
/>
</InputGroup>
</ToolbarFilter>
)
}

DatePickerFilter.propTypes = {
filterId: PropTypes.string.isRequired,
selectedFilters: PropTypes.array.isRequired,
showToolbarItem: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
onFilterUpdate: PropTypes.func.isRequired,

msg: PropTypes.object.isRequired,
}

export default withMsg(DatePickerFilter)
141 changes: 141 additions & 0 deletions src/components/Toolbar/Filters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { withMsg } from '_/intl'
import {
Button,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownPosition,
DropdownToggle,
InputGroup,
TextInput,
ToolbarGroup,
ToolbarFilter,
ToolbarItem,
ToolbarToggleGroup,
} from '@patternfly/react-core'

import { FilterIcon, SearchIcon } from '@patternfly/react-icons/dist/esm/icons'

import DatePickerFilter from './DatePickerFilter'
import SelectFilter from './SelectFilter'

const Filters = ({ msg, locale, selectedFilters, onFilterUpdate, filterTypes, textBasedFilterId }) => {
const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0])
const [expanded, setExpanded] = useState(false)
const [inputValue, setInputValue] = useState('')

const nameFilter = filterTypes.find(({ id }) => id === textBasedFilterId)
const labelToFilter = (label) => filterTypes.find(({ title }) => title === label) ?? currentFilterType

const onFilterTypeSelect = (event) => {
setCurrentFilterType(labelToFilter(event?.target?.innerText))
setExpanded(!expanded)
}
const onFilterTypeToggle = () => setExpanded(!expanded)
const onNameInput = (event) => {
if ((event.key && event.key !== 'Enter') ||
!inputValue ||
selectedFilters?.[textBasedFilterId]?.includes(inputValue)) {
return
}
onFilterUpdate({ ...selectedFilters, [textBasedFilterId]: [...(selectedFilters?.[textBasedFilterId] ?? []), inputValue] })
setInputValue('')
}

return (
<ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl">
<ToolbarGroup variant="filter-group">
<ToolbarItem>
<Dropdown
onSelect={onFilterTypeSelect}
position={DropdownPosition.left}
toggle={(
<DropdownToggle onToggle={onFilterTypeToggle} style={{ width: '100%' }}>
<FilterIcon /> {currentFilterType.title}
</DropdownToggle>
)}
isOpen={expanded}
style={{ width: '100%' }}
dropdownItems={
filterTypes.map(({ id, title }) =>
<DropdownItem key={id}>{title}</DropdownItem>)
}
/>
</ToolbarItem>
<ToolbarFilter
key={textBasedFilterId}
chips={selectedFilters?.[textBasedFilterId] ?? [] }
deleteChip={(category, option) => onFilterUpdate({
...selectedFilters,
[textBasedFilterId]: selectedFilters?.[textBasedFilterId]?.filter?.(value => value !== option) ?? [],
})}
deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [textBasedFilterId]: [] })}
categoryName={nameFilter.title}
showToolbarItem={currentFilterType.id === textBasedFilterId}
>
<InputGroup>
<TextInput
id={textBasedFilterId}
type="search"
onChange={setInputValue}
value={inputValue}
placeholder={nameFilter.placeholder}
onKeyDown={onNameInput}
/>
<Button
variant={ButtonVariant.control}
aria-label={msg.vmFilterTypePlaceholderName()}
onClick={onNameInput}
>
<SearchIcon />
</Button>
</InputGroup>
</ToolbarFilter>
{
filterTypes.filter(({ datePicker }) => datePicker).map(({ id: filterId, title }) => (
<DatePickerFilter
title={title}
key={filterId}
selectedFilters={selectedFilters?.[filterId] ?? []}
filterId={filterId}
showToolbarItem={currentFilterType.id === filterId}
onFilterUpdate={(filtersToSave) => {
console.warn('filtersToSave', filtersToSave)
onFilterUpdate({ ...selectedFilters, [filterId]: filtersToSave })
}
}
/>
))
}
{filterTypes.filter(({ filterValues }) => !!filterValues?.length)
?.map(({ id, filterValues, placeholder, title }) => (
<SelectFilter
title={title}
key={id}
filterColumnId={id}
showToolbarItem={currentFilterType.id === id}
filterIds={selectedFilters?.[id] ?? []}
allSupportedFilters={filterValues}
setFilters={(filtersToSave) => onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })}
title={title}
placeholderText={placeholder}
/>
)
)}
</ToolbarGroup>
</ToolbarToggleGroup>
)
}

Filters.propTypes = {
selectedFilters: PropTypes.object.isRequired,
filterTypes: PropTypes.array.isRequired,
textBasedFilterId: PropTypes.string.isRequired,
onFilterUpdate: PropTypes.func.isRequired,
msg: PropTypes.object.isRequired,
locale: PropTypes.string.isRequired,
}

export default withMsg(Filters)
79 changes: 79 additions & 0 deletions src/components/Toolbar/SelectFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import {
Select,
SelectOption,
SelectVariant,
ToolbarFilter,
} from '@patternfly/react-core'

const SelectFilter = ({ filterIds = [], setFilters, allSupportedFilters = [], title, placeholderText, filterColumnId, showToolbarItem }) => {
const [isExpanded, setExpanded] = useState(false)

// one label can map to many IDs so it's easier work with labels
// and reverse map label-to-IDs on save
const toChip = ({ title }) => title
const toOption = ({ title }) => title
const toOptionNode = ({ title }) =>
<SelectOption key={ title} value={title}/>

// titles are guaranteed to be unique
// return first filter with matching title
const labelToIds = (title) => {
const [{ ids = {} } = {}] = allSupportedFilters.filter(filter => filter.title === title) || []
return ids
}
const selectedFilters = allSupportedFilters.filter(({ ids }) => filterIds.find(id => ids[id]))
const deleteFilter = (title) => {
const ids = labelToIds(title)
// delete all filter IDs linked to provided title
setFilters(filterIds.filter(id => !ids[id]))
}

const addFilter = (title) => {
const ids = labelToIds(title)
// add all filter IDs linked
setFilters([...filterIds, ...Object.keys(ids)])
}
return (
<ToolbarFilter
key={filterColumnId}
chips={selectedFilters.map(toChip)}
deleteChip={(category, option) => deleteFilter(option)}
deleteChipGroup={() => setFilters([])}
categoryName={title}
showToolbarItem={showToolbarItem}
>
<Select
variant={SelectVariant.checkbox}
aria-label={placeholderText}
onSelect={(e, option, isPlaceholder) => {
if (isPlaceholder) {
return
}
event?.target?.checked
? addFilter(option)
: deleteFilter(option)
} }
selections={selectedFilters.map(toOption)}
placeholderText={placeholderText}
isOpen={isExpanded}
onToggle={setExpanded}
>
{allSupportedFilters.map(toOptionNode)}
</Select>
</ToolbarFilter>
)
}

SelectFilter.propTypes = {
filterIds: PropTypes.array.isRequired,
allSupportedFilters: PropTypes.array.isRequired,
setFilters: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
placeholderText: PropTypes.string.isRequired,
filterColumnId: PropTypes.string.isRequired,
showToolbarItem: PropTypes.bool.isRequired,
}

export default SelectFilter
83 changes: 83 additions & 0 deletions src/components/Toolbar/Sort.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'

import { withMsg } from '_/intl'
import { translate } from '_/helpers'
import {
Button,
OptionsMenu,
OptionsMenuItemGroup,
OptionsMenuSeparator,
OptionsMenuItem,
OptionsMenuToggle,
ToolbarGroup,
ToolbarItem,
} from '@patternfly/react-core'
import { SortAmountDownIcon, SortAmountDownAltIcon } from '@patternfly/react-icons/dist/esm/icons'

const Sort = ({ sort, msg, onSortChange, SortFields }) => {
const { id: enabledSortId, isAsc } = sort || {}
const [expanded, setExpanded] = useState(false)

const menuItems = [
<OptionsMenuItemGroup key="first group" aria-label={msg.sortColumn()}>
{Object.values(SortFields)
.map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) }))
.map(({ title, id, messageDescriptor }) => (
<OptionsMenuItem
id={id}
key={id}
isSelected={id === enabledSortId}
onSelect={() => onSortChange({ ...sort, id, messageDescriptor })}
>
{title}
</OptionsMenuItem>
))
}
</OptionsMenuItemGroup>,
<OptionsMenuSeparator key="separator"/>,
<OptionsMenuItemGroup key="second group" aria-label={msg.sortDirection()}>
<OptionsMenuItem onSelect={() => onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()}</OptionsMenuItem>
<OptionsMenuItem onSelect={() => onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()}</OptionsMenuItem>
</OptionsMenuItemGroup>,
]

return (
<ToolbarGroup variant='filter-group'>
<ToolbarItem >
<OptionsMenu
menuItems={menuItems}
isOpen={expanded}
toggle={(
<OptionsMenuToggle
onToggle={() => setExpanded(!expanded)}
toggleTemplate={sort?.messageDescriptor ? translate({ ...sort.messageDescriptor, msg }) : msg.sortBy()}
/>
)}
isGrouped
/>
</ToolbarItem>
<ToolbarItem>
<Button variant='plain' aria-label={msg.sortDirection()} onClick={() => onSortChange({ ...sort, isAsc: !isAsc })}>
{isAsc ? <SortAmountDownAltIcon/> : <SortAmountDownIcon/>}
</Button>
</ToolbarItem>
</ToolbarGroup>
)
}

Sort.propTypes = {
sort: PropTypes.shape({
id: PropTypes.string.isRequired,
messageDescriptor: PropTypes.object.isRequired,
isAsc: PropTypes.bool,
}),
SortFields: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
messageDescriptor: PropTypes.object.isRequired,
})).isRequired,
onSortChange: PropTypes.func.isRequired,
msg: PropTypes.object.isRequired,
}

export default withMsg(Sort)
Loading

0 comments on commit b52067d

Please sign in to comment.