From 7eff6490a474270d3c298532c352ab7b81867566 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Wed, 5 May 2021 00:27:40 +0200 Subject: [PATCH] Web UI: add checkbox, selects to filter toolbar Support three new filter types: checkbox, select and ternary select. The ternary select tool is meant to support fields that can be set to True, False, or ignored. Use this feature to allow filtering builds by held status (i.e. show builds that triggered a autohold only) or by voting status (i.e. show only voting jobs). Selects let a user choose among predefined values. Use a select to help a user choose a result value when searching builds and buildsets. Build page: add a thumbtack icon next to a build's status badge if that build triggered an autohold. Change-Id: I40b06c83ed069e0756c7f8b00430d36a36230bfa --- web/src/containers/FilterToolbar.jsx | 96 +++++++++++++-- web/src/containers/build/Build.jsx | 7 ++ web/src/containers/filters/Checkbox.jsx | 50 ++++++++ web/src/containers/filters/Select.jsx | 122 +++++++++++++++++++ web/src/containers/filters/TernarySelect.jsx | 113 +++++++++++++++++ web/src/pages/Builds.jsx | 48 +++++++- web/src/pages/Buildsets.jsx | 9 +- 7 files changed, 432 insertions(+), 13 deletions(-) create mode 100644 web/src/containers/filters/Checkbox.jsx create mode 100644 web/src/containers/filters/Select.jsx create mode 100644 web/src/containers/filters/TernarySelect.jsx diff --git a/web/src/containers/FilterToolbar.jsx b/web/src/containers/FilterToolbar.jsx index dcb7c4c437..23a652cb31 100644 --- a/web/src/containers/FilterToolbar.jsx +++ b/web/src/containers/FilterToolbar.jsx @@ -32,6 +32,11 @@ import { } from '@patternfly/react-core' import { FilterIcon, SearchIcon } from '@patternfly/react-icons' +import { FilterSelect } from './filters/Select' +import { FilterTernarySelect } from './filters/TernarySelect' +import { FilterCheckbox } from './filters/Checkbox' + + function FilterToolbar(props) { const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = useState(false) const [currentCategory, setCurrentCategory] = useState( @@ -89,9 +94,16 @@ function FilterToolbar(props) { // filter to be updated/removed. let newFilters = {} if (type) { - newFilters = { - ...filters, - [category.key]: filters[category.key].filter((s) => s !== id), + if (category.type === 'ternary') { + newFilters = { + ...filters, + [category.key]: [], + } + } else { + newFilters = { + ...filters, + [category.key]: filters[category.key].filter((s) => s !== id), + } } } else { // Delete the values for each filter category @@ -122,7 +134,12 @@ function FilterToolbar(props) { } isOpen={isCategoryDropdownOpen} - dropdownItems={filterCategories.map((category) => ( + dropdownItems={filterCategories.filter( + (category) => (category.type === 'search' || + category.type === 'select' || + category.type === 'ternary' || + category.type === 'checkbox') + ).map((category) => ( {category.title} ))} style={{ width: '100%' }} @@ -131,7 +148,8 @@ function FilterToolbar(props) { ) } - function renderFilterInput(category) { + function renderFilterInput(category, filters) { + const { onFilterChange } = props if (category.type === 'search') { return ( @@ -154,6 +172,37 @@ function FilterToolbar(props) { ) + } else if (category.type === 'select') { + return ( + + + + ) + } else if (category.type === 'ternary') { + return ( + + + + ) + } else if (category.type === 'checkbox') { + return ( + +
+ +
+ ) } } @@ -165,12 +214,12 @@ function FilterToolbar(props) { {filterCategories.map((category) => ( handleDelete(type, id, category)} categoryName={category.title} showToolbarItem={currentCategory === category.title} > - {renderFilterInput(category)} + {renderFilterInput(category, filters)} ))} @@ -203,14 +252,45 @@ FilterToolbar.propTypes = { filterCategories: PropTypes.array.isRequired, } +function getChipsFromFilters(filters, category) { + if (category.type === 'ternary') { + switch ([...filters[category.key]].pop()) { + case 1: + case '1': + return ['True',] + case 0: + case '0': + return ['False',] + default: + return [] + } + } else { + return filters[category.key] + } +} + function getFiltersFromUrl(location, filterCategories) { const urlParams = new URLSearchParams(location.search) const filters = filterCategories.reduce((filterDict, item) => { // Initialize each filter category with an empty list filterDict[item.key] = [] + // And update the list with each matching element from the URL query urlParams.getAll(item.key).forEach((param) => { - filterDict[item.key].push(param) + if (item.type === 'checkbox') { + switch (param) { + case '1': + filterDict[item.key].push(1) + break + case '0': + filterDict[item.key].push(0) + break + default: + break + } + } else { + filterDict[item.key].push(param) + } }) return filterDict }, {}) diff --git a/web/src/containers/build/Build.jsx b/web/src/containers/build/Build.jsx index 03b2d27044..c5ebbe08c6 100644 --- a/web/src/containers/build/Build.jsx +++ b/web/src/containers/build/Build.jsx @@ -29,6 +29,7 @@ import { OutlinedCalendarAltIcon, OutlinedClockIcon, StreamIcon, + ThumbtackIcon, } from '@patternfly/react-icons' import * as moment from 'moment' import 'moment-duration-format' @@ -57,6 +58,12 @@ function Build({ build, tenant, timezone }) { {build.job_name} {!build.voting && ' (non-voting)'} + {build.held && + } {/* We handle the spacing for the body and the flex items by ourselves so they go hand in hand. By default, the flex items' spacing only diff --git a/web/src/containers/filters/Checkbox.jsx b/web/src/containers/filters/Checkbox.jsx new file mode 100644 index 0000000000..6fdd7af269 --- /dev/null +++ b/web/src/containers/filters/Checkbox.jsx @@ -0,0 +1,50 @@ +// Copyright 2021 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import React from 'react' +import PropTypes from 'prop-types' + +import { + Checkbox, +} from '@patternfly/react-core' + + +function FilterCheckbox(props) { + const { filters, category, onFilterChange } = props + + function onChange(checked) { + const newFilters = { + ...filters, + [category.key]: checked ? [1,] : [], + } + onFilterChange(newFilters) + } + + return ( + + ) +} + +FilterCheckbox.propTypes = { + onFilterChange: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + category: PropTypes.object.isRequired, +} + +export { FilterCheckbox } \ No newline at end of file diff --git a/web/src/containers/filters/Select.jsx b/web/src/containers/filters/Select.jsx new file mode 100644 index 0000000000..3e99c8c262 --- /dev/null +++ b/web/src/containers/filters/Select.jsx @@ -0,0 +1,122 @@ +// Copyright 2021 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import { + Chip, + ChipGroup, + Select, + SelectOption, + SelectVariant, +} from '@patternfly/react-core' + + +function FilterSelect(props) { + const { filters, category } = props + + const [isOpen, setIsOpen] = useState(false) + const [selected, setSelected] = useState(filters[category.key]) + const [options, setOptions] = useState(category.options) + + function onToggle(isOpen) { + setSelected(filters[category.key]) + setIsOpen(isOpen) + } + + function onSelect(event, selection) { + const { onFilterChange, filters, category } = props + + const newSelected = selected.includes(selection) + ? selected.filter(item => item !== selection) + : [...selected, selection] + + setSelected(newSelected) + const newFilters = { + ...filters, + [category.key]: newSelected, + } + onFilterChange(newFilters) + } + + function onClear() { + const { onFilterChange, filters, category } = props + setSelected([]) + setIsOpen(false) + const newFilters = { + ...filters, + [category.key]: [], + } + onFilterChange(newFilters) + } + + function chipGroupComponent() { + const { filters, category } = props + const chipped = filters[category.key] + return ( + + {chipped.map((currentChip, index) => ( + onSelect(e, currentChip)} + > + {currentChip} + + ))} + + ) + } + + function onCreateOption(newValue) { + const newOptions = [...options, newValue] + setOptions(newOptions) + } + + return ( + + ) +} + +FilterSelect.propTypes = { + onFilterChange: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + category: PropTypes.object.isRequired, +} + +export { FilterSelect } diff --git a/web/src/containers/filters/TernarySelect.jsx b/web/src/containers/filters/TernarySelect.jsx new file mode 100644 index 0000000000..9905db6dfe --- /dev/null +++ b/web/src/containers/filters/TernarySelect.jsx @@ -0,0 +1,113 @@ +// Copyright 2021 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +import React, { useState } from 'react' +import PropTypes from 'prop-types' + +import { + Select, + SelectOption, + SelectVariant, +} from '@patternfly/react-core' + + +function FilterTernarySelect(props) { + /* The ternary select expects the options to be in order: All/True/False */ + const { filters, category } = props + + const [isOpen, setIsOpen] = useState(false) + const options = category.options + const _getSelected = (value) => { + switch (value) { + case 1: + case '1': + return category.options[1] + case 0: + case '0': + return category.options[2] + default: + return null + } + } + let _selected = _getSelected([...filters[category.key]].pop()) + const [selected, setSelected] = useState(_selected) + + function onToggle(isOpen) { + setIsOpen(isOpen) + } + + function onSelect(event, selection) { + const { onFilterChange, filters, category } = props + + let _selection = (selection === selected) ? null : selection + + setSelected(_selection) + const setNewFilter = (value) => { + switch (value) { + case category.options[1]: + return [1,] + case category.options[2]: + return [0,] + default: + return [] + } + } + const newFilters = { + ...filters, + [category.key]: setNewFilter(_selection), + } + onFilterChange(newFilters) + setIsOpen(false) + } + + function onClear() { + const { onFilterChange, filters, category } = props + setSelected(null) + setIsOpen(false) + const newFilters = { + ...filters, + [category.key]: [], + } + onFilterChange(newFilters) + } + + return ( + + ) +} + +FilterTernarySelect.propTypes = { + onFilterChange: PropTypes.func.isRequired, + filters: PropTypes.object.isRequired, + category: PropTypes.object.isRequired, +} + +export { FilterTernarySelect } \ No newline at end of file diff --git a/web/src/pages/Builds.jsx b/web/src/pages/Builds.jsx index bb4e4cf474..da78241de4 100644 --- a/web/src/pages/Builds.jsx +++ b/web/src/pages/Builds.jsx @@ -68,12 +68,32 @@ class BuildsPage extends React.Component { placeholder: 'Filter by Change...', type: 'search', }, - // TODO (felix): We could change the result filter to a dropdown later on { key: 'result', title: 'Result', - placeholder: 'Filter by Result...', - type: 'search', + placeholder: 'Any result', + type: 'select', + // TODO there should be a single source of truth for this + options: [ + 'SUCCESS', + 'FAILURE', + 'RETRY_LIMIT', + 'POST_FAILURE', + 'SKIPPED', + 'NODE_FAILURE', + 'MERGER_FAILURE', + 'CONFIG_ERROR', + 'TIMED_OUT', + 'CANCELED', + 'ERROR', + 'RETRY', + 'DISK_FULL', + 'NO_JOBS', + 'DISCONNECT', + 'ABORTED', + 'LOST', + 'EXCEPTION', + 'NO_HANDLE'], }, { key: 'uuid', @@ -81,6 +101,28 @@ class BuildsPage extends React.Component { placeholder: 'Filter by Build UUID...', type: 'search', }, + { + key: 'held', + title: 'Held', + placeholder: 'Choose Hold Status...', + type: 'ternary', + options: [ + 'All', + 'Held Builds Only', + 'Non Held Builds Only', + ] + }, + { + key: 'voting', + title: 'Voting', + placeholder: 'Choose Voting Status...', + type: 'ternary', + options: [ + 'All', + 'Voting Only', + 'Non-Voting Only', + ] + }, ] this.state = { diff --git a/web/src/pages/Buildsets.jsx b/web/src/pages/Buildsets.jsx index a9372e8b01..62978308ec 100644 --- a/web/src/pages/Buildsets.jsx +++ b/web/src/pages/Buildsets.jsx @@ -60,12 +60,17 @@ class BuildsetsPage extends React.Component { placeholder: 'Filter by Change...', type: 'search', }, - // TODO (felix): We could change the result filter to a dropdown later on { key: 'result', title: 'Result', placeholder: 'Filter by Result...', - type: 'search', + type: 'select', + // are there more? + options: [ + 'SUCCESS', + 'FAILURE', + 'MERGER_FAILURE', + ] }, { key: 'uuid',