zuul/web/src/containers/FilterToolbar.jsx
Matthieu Huin 7eff6490a4 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
2021-09-09 19:13:55 +02:00

329 lines
9.0 KiB
JavaScript

// Copyright 2020 BMW Group
//
// 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 {
Button,
ButtonVariant,
Dropdown,
DropdownItem,
DropdownPosition,
DropdownToggle,
InputGroup,
TextInput,
Toolbar,
ToolbarContent,
ToolbarFilter,
ToolbarGroup,
ToolbarItem,
ToolbarToggleGroup,
} 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(
props.filterCategories[0].title
)
const [inputValue, setInputValue] = useState('')
function handleCategoryToggle(isOpen) {
setIsCategoryDropdownOpen(isOpen)
}
function handleCategorySelect(event) {
setCurrentCategory(event.target.innerText)
setIsCategoryDropdownOpen(!isCategoryDropdownOpen)
}
function handleInputChange(newValue) {
setInputValue(newValue)
}
function handleInputSend(event, category) {
const { onFilterChange, filters } = props
// In case the event comes from a key press, only accept "Enter"
if (event.key && event.key !== 'Enter') {
return
}
// Ignore empty values
if (!inputValue) {
return
}
const prevFilters = filters[category.key]
const newFilters = {
...filters,
[category.key]: prevFilters.includes(inputValue)
? prevFilters
: [...prevFilters, inputValue],
}
// Clear the input field
setInputValue('')
// Notify the parent component about the filter change
onFilterChange(newFilters)
}
function handleDelete(type = '', id = '', category) {
const { filterCategories, filters, onFilterChange } = props
// Usually the type contains the category for which a chip should be deleted
// If the type is set, we got a delete() call for a single chip. The type
// reflects the name of the Chip group which does not necessarily go in hand
// with our category keys. Thus, we use the category to identify the correct
// filter to be updated/removed.
let newFilters = {}
if (type) {
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
newFilters = filterCategories.reduce((filterDict, category) => {
filterDict[category.key] = []
return filterDict
}, {})
}
// Notify the parent component about the filter change
onFilterChange(newFilters)
}
function renderCategoryDropdown() {
const { filterCategories } = props
return (
<ToolbarItem>
<Dropdown
onSelect={handleCategorySelect}
position={DropdownPosition.left}
toggle={
<DropdownToggle
onToggle={handleCategoryToggle}
style={{ width: '100%' }}
>
<FilterIcon /> {currentCategory}
</DropdownToggle>
}
isOpen={isCategoryDropdownOpen}
dropdownItems={filterCategories.filter(
(category) => (category.type === 'search' ||
category.type === 'select' ||
category.type === 'ternary' ||
category.type === 'checkbox')
).map((category) => (
<DropdownItem key={category.key}>{category.title}</DropdownItem>
))}
style={{ width: '100%' }}
/>
</ToolbarItem>
)
}
function renderFilterInput(category, filters) {
const { onFilterChange } = props
if (category.type === 'search') {
return (
<InputGroup>
<TextInput
name={`${category.key}-input`}
id={`${category.key}-input`}
type="search"
aria-label={`${category.key} filter`}
onChange={handleInputChange}
value={inputValue}
placeholder={category.placeholder}
onKeyDown={(event) => handleInputSend(event, category)}
/>
<Button
variant={ButtonVariant.control}
aria-label="search button for search input"
onClick={(event) => handleInputSend(event, category)}
>
<SearchIcon />
</Button>
</InputGroup>
)
} else if (category.type === 'select') {
return (
<InputGroup>
<FilterSelect
onFilterChange={onFilterChange}
filters={filters}
category={category}
/>
</InputGroup>
)
} else if (category.type === 'ternary') {
return (
<InputGroup>
<FilterTernarySelect
onFilterChange={onFilterChange}
filters={filters}
category={category}
/>
</InputGroup>
)
} else if (category.type === 'checkbox') {
return (
<InputGroup>
<br />
<FilterCheckbox
onFilterChange={onFilterChange}
filters={filters}
category={category}
/>
</InputGroup>
)
}
}
function renderFilterDropdown() {
const { filterCategories, filters } = props
return (
<>
{filterCategories.map((category) => (
<ToolbarFilter
key={category.key}
chips={getChipsFromFilters(filters, category)}
deleteChip={(type, id) => handleDelete(type, id, category)}
categoryName={category.title}
showToolbarItem={currentCategory === category.title}
>
{renderFilterInput(category, filters)}
</ToolbarFilter>
))}
</>
)
}
return (
<>
<Toolbar
id="toolbar-with-chip-groups"
clearAllFilters={handleDelete}
collapseListedFiltersBreakpoint="md"
>
<ToolbarContent>
<ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="md">
<ToolbarGroup variant="filter-group">
{renderCategoryDropdown()}
{renderFilterDropdown()}
</ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
</>
)
}
FilterToolbar.propTypes = {
onFilterChange: PropTypes.func.isRequired,
filters: PropTypes.object.isRequired,
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) => {
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
}, {})
return filters
}
function writeFiltersToUrl(filters, location, history) {
// Build new URL parameters from the filters in state
const searchParams = new URLSearchParams('')
Object.keys(filters).map((key) => {
filters[key].forEach((value) => {
searchParams.append(key, value)
})
return searchParams
})
history.push({
pathname: location.pathname,
search: searchParams.toString(),
})
}
function buildQueryString(filters) {
let queryString = '&complete=true'
if (filters) {
Object.keys(filters).map((key) => {
filters[key].forEach((value) => {
queryString += '&' + key + '=' + value
})
return queryString
})
}
return queryString
}
export { buildQueryString, FilterToolbar, getFiltersFromUrl, writeFiltersToUrl }