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
This commit is contained in:
Matthieu Huin 2021-05-05 00:27:40 +02:00
parent fd599b1019
commit 7eff6490a4
7 changed files with 432 additions and 13 deletions

View File

@ -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,10 +94,17 @@ function FilterToolbar(props) {
// 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) => {
@ -122,7 +134,12 @@ function FilterToolbar(props) {
</DropdownToggle>
}
isOpen={isCategoryDropdownOpen}
dropdownItems={filterCategories.map((category) => (
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%' }}
@ -131,7 +148,8 @@ function FilterToolbar(props) {
)
}
function renderFilterInput(category) {
function renderFilterInput(category, filters) {
const { onFilterChange } = props
if (category.type === 'search') {
return (
<InputGroup>
@ -154,6 +172,37 @@ function FilterToolbar(props) {
</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>
)
}
}
@ -165,12 +214,12 @@ function FilterToolbar(props) {
{filterCategories.map((category) => (
<ToolbarFilter
key={category.key}
chips={filters[category.key]}
chips={getChipsFromFilters(filters, category)}
deleteChip={(type, id) => handleDelete(type, id, category)}
categoryName={category.title}
showToolbarItem={currentCategory === category.title}
>
{renderFilterInput(category)}
{renderFilterInput(category, filters)}
</ToolbarFilter>
))}
</>
@ -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) => {
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
}, {})

View File

@ -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)'}
</BuildResultWithIcon>
<BuildResultBadge result={build.result} />
{build.held &&
<ThumbtackIcon title="This build triggered an autohold"
style={{
marginLeft: 'var(--pf-global--spacer--sm)',
}}
/>}
</Title>
{/* 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

View File

@ -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 (
<Checkbox
label={category.placeholder}
isChecked={[...filters[category.key]].pop() ? true : false}
onChange={onChange}
aria-label={category.placeholder}
/>
)
}
FilterCheckbox.propTypes = {
onFilterChange: PropTypes.func.isRequired,
filters: PropTypes.object.isRequired,
category: PropTypes.object.isRequired,
}
export { FilterCheckbox }

View File

@ -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 (
<ChipGroup>
{chipped.map((currentChip, index) => (
<Chip
isReadOnly={index === 0 ? true : false}
key={currentChip}
onClick={(e) => onSelect(e, currentChip)}
>
{currentChip}
</Chip>
))}
</ChipGroup>
)
}
function onCreateOption(newValue) {
const newOptions = [...options, newValue]
setOptions(newOptions)
}
return (
<Select
chipGroupProps={{ numChips: 1, expandedText: 'Hide' }}
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel={category.title}
onToggle={onToggle}
onClear={onClear}
onSelect={onSelect}
selections={filters[category.key]}
isOpen={isOpen}
isCreatable="true"
onCreateOption={onCreateOption}
placeholderText={category.placeholder}
chipGroupComponent={chipGroupComponent}
maxHeight={300}
>
{
options.map((option, index) => (
<SelectOption
key={index}
value={option}
/>
))
}
</Select >
)
}
FilterSelect.propTypes = {
onFilterChange: PropTypes.func.isRequired,
filters: PropTypes.object.isRequired,
category: PropTypes.object.isRequired,
}
export { FilterSelect }

View File

@ -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 (
<Select
variant={SelectVariant.single}
placeholderText={category.placeholder}
isOpen={isOpen}
onToggle={onToggle}
onSelect={onSelect}
onClear={onClear}
selections={_selected}
>
{
options.map((option, index) => (
<SelectOption
key={index}
value={option}
/>
))
}
</Select>
)
}
FilterTernarySelect.propTypes = {
onFilterChange: PropTypes.func.isRequired,
filters: PropTypes.object.isRequired,
category: PropTypes.object.isRequired,
}
export { FilterTernarySelect }

View File

@ -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 = {

View File

@ -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',