From b52067d0ac29761c5fa8e06f905638d950cda596 Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Mon, 6 Jun 2022 13:59:54 +0200 Subject: [PATCH] Extract toolbar filtering and sorting Before, filtering and sorting was tied to VM entities. After this patch the generic filter/sort component can be reused for other entities. --- src/components/Toolbar/DatePickerFilter.js | 78 +++++++++ src/components/Toolbar/Filters.js | 141 ++++++++++++++++ src/components/Toolbar/SelectFilter.js | 79 +++++++++ src/components/Toolbar/Sort.js | 83 ++++++++++ src/components/Toolbar/VmFilters.js | 179 +-------------------- src/components/Toolbar/VmSort.js | 71 +------- src/utils/vms-sort.js | 3 - 7 files changed, 393 insertions(+), 241 deletions(-) create mode 100644 src/components/Toolbar/DatePickerFilter.js create mode 100644 src/components/Toolbar/Filters.js create mode 100644 src/components/Toolbar/SelectFilter.js create mode 100644 src/components/Toolbar/Sort.js diff --git a/src/components/Toolbar/DatePickerFilter.js b/src/components/Toolbar/DatePickerFilter.js new file mode 100644 index 000000000..116661e5a --- /dev/null +++ b/src/components/Toolbar/DatePickerFilter.js @@ -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 ( + clearSingleDate(option)} + deleteChipGroup={() => onFilterUpdate([])} + categoryName={title} + showToolbarItem={showToolbarItem} + > + + + + + ) +} + +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) diff --git a/src/components/Toolbar/Filters.js b/src/components/Toolbar/Filters.js new file mode 100644 index 000000000..f8b108fdf --- /dev/null +++ b/src/components/Toolbar/Filters.js @@ -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 ( + } breakpoint="xl"> + + + + {currentFilterType.title} + + )} + isOpen={expanded} + style={{ width: '100%' }} + dropdownItems={ + filterTypes.map(({ id, title }) => + {title}) + } + /> + + onFilterUpdate({ + ...selectedFilters, + [textBasedFilterId]: selectedFilters?.[textBasedFilterId]?.filter?.(value => value !== option) ?? [], + })} + deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [textBasedFilterId]: [] })} + categoryName={nameFilter.title} + showToolbarItem={currentFilterType.id === textBasedFilterId} + > + + + + + + { + filterTypes.filter(({ datePicker }) => datePicker).map(({ id: filterId, title }) => ( + { + console.warn('filtersToSave', filtersToSave) + onFilterUpdate({ ...selectedFilters, [filterId]: filtersToSave }) + } + } + /> + )) + } + {filterTypes.filter(({ filterValues }) => !!filterValues?.length) + ?.map(({ id, filterValues, placeholder, title }) => ( + onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })} + title={title} + placeholderText={placeholder} + /> + ) + )} + + + ) +} + +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) diff --git a/src/components/Toolbar/SelectFilter.js b/src/components/Toolbar/SelectFilter.js new file mode 100644 index 000000000..7819de314 --- /dev/null +++ b/src/components/Toolbar/SelectFilter.js @@ -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 }) => + + + // 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 ( + deleteFilter(option)} + deleteChipGroup={() => setFilters([])} + categoryName={title} + showToolbarItem={showToolbarItem} + > + + + ) +} + +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 diff --git a/src/components/Toolbar/Sort.js b/src/components/Toolbar/Sort.js new file mode 100644 index 000000000..8c7961710 --- /dev/null +++ b/src/components/Toolbar/Sort.js @@ -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 = [ + + {Object.values(SortFields) + .map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) })) + .map(({ title, id, messageDescriptor }) => ( + onSortChange({ ...sort, id, messageDescriptor })} + > + {title} + + )) + } + , + , + + onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()} + onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()} + , + ] + + return ( + + + setExpanded(!expanded)} + toggleTemplate={sort?.messageDescriptor ? translate({ ...sort.messageDescriptor, msg }) : msg.sortBy()} + /> + )} + isGrouped + /> + + + + + + ) +} + +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) diff --git a/src/components/Toolbar/VmFilters.js b/src/components/Toolbar/VmFilters.js index 383dd7d48..1f9116cdf 100644 --- a/src/components/Toolbar/VmFilters.js +++ b/src/components/Toolbar/VmFilters.js @@ -1,28 +1,11 @@ -import React, { useState, useMemo } from 'react' +import React, { useMemo } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import { enumMsg, withMsg } from '_/intl' import { saveVmsFilters } from '_/actions' import { localeCompare, toJS } from '_/helpers' -import { - Button, - ButtonVariant, - Dropdown, - DropdownItem, - DropdownPosition, - DropdownToggle, - InputGroup, - Select, - SelectOption, - SelectVariant, - TextInput, - ToolbarGroup, - ToolbarFilter, - ToolbarItem, - ToolbarToggleGroup, -} from '@patternfly/react-core' -import { FilterIcon, SearchIcon } from '@patternfly/react-icons/dist/esm/icons' +import Filters from './Filters' const STATUS = 'status' const OS = 'os' @@ -79,74 +62,6 @@ const composeOs = (msg, locale, operatingSystems) => { }) } -const Filter = ({ filterIds = [], setFilters, allSupportedFilters = [], title, 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 }) => - - - // 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 ( - deleteFilter(option)} - deleteChipGroup={() => setFilters([])} - categoryName={filterColumnId} - showToolbarItem={showToolbarItem} - > - - - ) -} - -Filter.propTypes = { - filterIds: PropTypes.array.isRequired, - allSupportedFilters: PropTypes.array.isRequired, - setFilters: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, - filterColumnId: PropTypes.string.isRequired, - showToolbarItem: PropTypes.bool.isRequired, -} - const VmFilters = ({ msg, locale, operatingSystems, selectedFilters, onFilterUpdate }) => { const filterTypes = useMemo(() => [ { @@ -157,91 +72,13 @@ const VmFilters = ({ msg, locale, operatingSystems, selectedFilters, onFilterUpd composeStatus(msg, locale), composeOs(msg, locale, operatingSystems), ], [msg, locale, operatingSystems]) - const [currentFilterType, setCurrentFilterType] = useState(filterTypes[0]) - const [expanded, setExpanded] = useState(false) - const [inputValue, setInputValue] = useState('') - - const nameFilter = filterTypes.find(({ id }) => id === NAME) - 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?.[NAME]?.includes(inputValue)) { - return - } - onFilterUpdate({ ...selectedFilters, [NAME]: [...(selectedFilters?.[NAME] ?? []), inputValue] }) - setInputValue('') - } - return ( - } breakpoint="xl"> - - - - {currentFilterType.title} - - )} - isOpen={expanded} - style={{ width: '100%' }} - dropdownItems={ - filterTypes.map(({ id, title }) => - {title}) - } - /> - - onFilterUpdate({ - ...selectedFilters, - [NAME]: selectedFilters?.[NAME]?.filter?.(value => value !== option) ?? [], - })} - deleteChipGroup={() => onFilterUpdate({ ...selectedFilters, [NAME]: [] })} - categoryName={NAME} - showToolbarItem={currentFilterType.id === NAME} - > - - - - - - {filterTypes.filter(({ id }) => id !== NAME)?.map(({ id, filterValues, placeholder }) => ( - onFilterUpdate({ ...selectedFilters, [id]: filtersToSave })} - title={placeholder} - /> - ) - )} - - + ) } diff --git a/src/components/Toolbar/VmSort.js b/src/components/Toolbar/VmSort.js index 7f0d8cc1a..6ec0e6467 100644 --- a/src/components/Toolbar/VmSort.js +++ b/src/components/Toolbar/VmSort.js @@ -1,83 +1,20 @@ -import React, { useState } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' - -import { setVmSort } from '_/actions' +import Sort from './Sort' import { SortFields } from '_/utils' +import { setVmSort } from '_/actions' 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 VmSort = ({ sort, msg, onSortChange }) => { - const { id: enabledSortId, isAsc } = sort - const [expanded, setExpanded] = useState(false) - - const menuItems = [ - - {Object.values(SortFields) - .map(type => ({ ...type, title: translate({ ...type.messageDescriptor, msg }) })) - .map(({ title, id, messageDescriptor }) => ( - onSortChange({ ...sort, id, messageDescriptor })} - > - {title} - - )) - } - , - , - - onSortChange({ ...sort, isAsc: true })} isSelected={isAsc} id="ascending" key="ascending">{msg.ascending()} - onSortChange({ ...sort, isAsc: false })} isSelected={!isAsc} id="descending" key="descending">{msg.descending()} - , - ] - - return ( - - - setExpanded(!expanded)} - toggleTemplate={sort?.messageDescriptor ? translate({ ...sort.messageDescriptor, msg }) : msg.sortBy()} - /> - )} - isGrouped - /> - - - - - - ) -} +const VmSort = ({ sort, onSortChange }) => VmSort.propTypes = { sort: PropTypes.shape({ id: PropTypes.string.isRequired, messageDescriptor: PropTypes.object.isRequired, - isNumeric: PropTypes.bool, isAsc: PropTypes.bool, }).isRequired, onSortChange: PropTypes.func.isRequired, - msg: PropTypes.object.isRequired, } export default connect( diff --git a/src/utils/vms-sort.js b/src/utils/vms-sort.js index b683a07ae..eba1f6c36 100644 --- a/src/utils/vms-sort.js +++ b/src/utils/vms-sort.js @@ -11,17 +11,14 @@ const getFieldValueMap = (msg) => ({ export const SortFields = { NAME: { id: 'name', - isNumeric: false, messageDescriptor: { id: 'name' }, }, OS: { id: 'os', - isNumeric: false, messageDescriptor: { id: 'operatingSystem' }, }, STATUS: { id: 'status', - isNumeric: false, messageDescriptor: { id: 'status' }, }, }