web: Add an option to sort pipelines

This adds a dropdown menu giving users an option to sort pipelines on
the PipelineOverview page, either by the initial implicit sorting,
alphabetically by name, or by the number of items in the pipelines.

This is to give users more control over how pipelines are displayed,
especially in large tenants with many pipelines.

Change-Id: I5475a1de77851351b2e776e78cffef5815d0c2f2
This commit is contained in:
Benjamin Schanzel
2025-01-09 16:11:19 +01:00
parent 380e131ed5
commit 6df1988353
9 changed files with 129 additions and 11 deletions

View File

@@ -390,9 +390,18 @@ function getFiltersFromUrl(location, filterCategories) {
return filters
}
function writeFiltersToUrl(filters, location, history) {
function writeFiltersToUrl(filters, filterCategories, location, history) {
// Build new URL parameters from the filters in state
const searchParams = new URLSearchParams('')
const searchParams = new URLSearchParams(location.search)
// first clear existing searchParams contained in the current valid
// filterCategories or "skip"/"limit". This is to make sure we don't remove
// other unrelated searchParams
const keys = filterCategories.map(c => c.key).concat(['skip', 'limit'])
for (const key of keys) {
searchParams.delete(key)
}
Object.keys(filters).map((key) => {
filters[key].forEach((value) => {
searchParams.append(key, value)

View File

@@ -0,0 +1,75 @@
// Copyright 2025 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 { Dropdown, DropdownItem, DropdownPosition, DropdownToggle, Tooltip } from '@patternfly/react-core'
import { SortAmountDownIcon } from '@patternfly/react-icons'
function getSearchKeyFromUrl(location, sortKeys) {
const searchParams = new URLSearchParams(location.search)
const searchKey = searchParams.get('sort')
if (!searchKey) {
return null
}
return sortKeys.find(k => k.key === searchKey)
}
function writeSearchKeyToUrl(sortKey, location, history) {
const searchParams = new URLSearchParams(location.search)
searchParams.set('sort', sortKey.key)
history.push({
pathname: location.pathname,
search: searchParams.toString(),
})
}
function SortDropdown({sortKeys, selectedSortKey, onSortKeyChange}) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
function onDropdownSelect(event) {
const sortKey = sortKeys.find(k => k.title === event.target.innerText)
onSortKeyChange(sortKey)
setIsDropdownOpen(false)
}
return (
<Tooltip content="Sort pipelines by...">
<Dropdown
position={DropdownPosition.left}
onSelect={onDropdownSelect}
toggle={
<DropdownToggle
onToggle={setIsDropdownOpen}>
<SortAmountDownIcon/>&nbsp;
{selectedSortKey.title}
</DropdownToggle>
}
isOpen={isDropdownOpen}
dropdownItems={sortKeys.map((k) =>
<DropdownItem key={k.key}>{k.title}</DropdownItem>
)}
/>
</Tooltip>
)
}
SortDropdown.propTypes = {
sortKeys: PropTypes.array.isRequired,
selectedSortKey: PropTypes.object.isRequired,
onSortKeyChange: PropTypes.func.isRequired,
}
export { SortDropdown, getSearchKeyFromUrl, writeSearchKeyToUrl }

View File

@@ -15,8 +15,8 @@
import { applyFilter, isFilterActive, writeFiltersToUrl } from '../FilterToolbar'
import { isPipelineEmpty } from './Misc'
function handleFilterChange(newFilters, location, history) {
writeFiltersToUrl(newFilters, location, history)
function handleFilterChange(newFilters, filterCategories, location, history) {
writeFiltersToUrl(newFilters, filterCategories, location, history)
}
function clearFilters(location, history, filterCategories) {
@@ -25,7 +25,7 @@ function clearFilters(location, history, filterCategories) {
filterDict[category.key] = []
return filterDict
}, {})
handleFilterChange(filters, location, history)
handleFilterChange(filters, filterCategories, location, history)
}
function filterInputValidation(_, filterValue) {

View File

@@ -1034,3 +1034,7 @@ details.foldable[open] summary::before {
transparent
);
}
.sort-toolbar {
padding: 0 !important;
}

View File

@@ -232,7 +232,7 @@ class BuildsPage extends React.Component {
// We must update the URL parameters before the state. Otherwise, the URL
// will always be one filter selection behind the state. But as the URL
// reflects our state this should be ok.
writeFiltersToUrl(finalFilters, location, history)
writeFiltersToUrl(finalFilters, this.filterCategories, location, history)
let newState = {
filters: finalFilters,
// if filters haven't changed besides skip or limit, keep our itemCount and currentPage

View File

@@ -187,7 +187,7 @@ class BuildsetsPage extends React.Component {
// will always be one filter selection behind the state. But as the URL
// reflects our state this should be ok.
writeFiltersToUrl(newFilters, location, history)
writeFiltersToUrl(newFilters, this.filterCategories, location, history)
let newState = {
filters: finalFilters,
// if filters haven't changed besides skip or limit, keep our itemCount and currentPage

View File

@@ -204,7 +204,7 @@ class ConfigErrorsPage extends React.Component {
// We must update the URL parameters before the state. Otherwise, the URL
// will always be one filter selection behind the state. But as the URL
// reflects our state this should be ok.
writeFiltersToUrl(finalFilters, location, history)
writeFiltersToUrl(finalFilters, this.filterCategories, location, history)
const newState = {
filters: finalFilters,
// if filters haven't changed besides skip or limit, keep our itemCount and currentPage

View File

@@ -200,7 +200,7 @@ function PipelineDetailsPage({
<LevelItem>
<FilterToolbar
filterCategories={filterCategories}
onFilterChange={(newFilters) => { handleFilterChange(newFilters, location, history) }}
onFilterChange={(newFilters) => { handleFilterChange(newFilters, filterCategories, location, history) }}
filters={filters}
filterInputValidation={filterInputValidation}
>

View File

@@ -46,6 +46,7 @@ import {
ToolbarStatsGroup,
ToolbarStatsItem,
} from '../containers/FilterToolbar'
import { getSearchKeyFromUrl, writeSearchKeyToUrl, SortDropdown } from '../containers/SortDropdown'
import {
clearFilters,
filterInputValidation,
@@ -84,12 +85,24 @@ const filterCategories = [
]
function PipelineGallery({ pipelines, tenant, showAllPipelines, expandAll, isLoading, filters, onClearFilters }) {
function PipelineGallery({ pipelines, tenant, showAllPipelines, expandAll, isLoading, filters, onClearFilters, sortKey }) {
// Filter out empty pipelines if necessary
if (!showAllPipelines) {
pipelines = pipelines.filter(ppl => ppl._count > 0)
}
switch(sortKey) {
case 'name':
pipelines = [...pipelines].sort((p1, p2) => p1.name.localeCompare(p2.name))
break
case 'length':
pipelines = [...pipelines].sort((p1, p2) => p2._count - p1._count)
break
default:
pipelines = [...pipelines]
break
}
return (
<>
<Gallery
@@ -126,6 +139,7 @@ PipelineGallery.propTypes = {
isLoading: PropTypes.bool,
filters: PropTypes.object,
onClearFilters: PropTypes.func,
sortKey: PropTypes.string,
}
function getPipelines(status, location) {
@@ -178,6 +192,18 @@ function PipelineOverviewPage() {
const timezone = useSelector((state) => state.timezone)
const dispatch = useDispatch()
const sortKeys = [
{key: 'none', title: 'Preset'},
{key: 'name', title: 'Name'},
{key: 'length', title: 'Length'},
]
const [currentSortKey, setCurrentSortKey] = useState(getSearchKeyFromUrl(location, sortKeys) || sortKeys[0])
const onSortKeyChanged = (sortKey) => {
setCurrentSortKey(sortKey)
writeSearchKeyToUrl(sortKey, location, history)
}
const onShowAllPipelinesToggle = (isChecked) => {
setShowAllPipelines(isChecked)
localStorage.setItem('zuul_show_all_pipelines', isChecked.toString())
@@ -190,7 +216,7 @@ function PipelineOverviewPage() {
}
const onFilterChanged = (newFilters) => {
handleFilterChange(newFilters, location, history)
handleFilterChange(newFilters, filterCategories, location, history)
// show all pipelines when filtering, hide when not
setShowAllPipelines(
isFilterActive(newFilters) || localStorage.getItem('zuul_show_all_pipelines') === 'true')
@@ -262,6 +288,9 @@ function PipelineOverviewPage() {
filters={filters}
filterInputValidation={filterInputValidation}
>
<ToolbarItem>
<SortDropdown sortKeys={sortKeys} selectedSortKey={currentSortKey} onSortKeyChange={onSortKeyChanged}/>
</ToolbarItem>
<ToolbarItem>
{filterActive ?
<Tooltip content="Disabled when filtering">{allPipelinesSwitch}</Tooltip> :
@@ -327,6 +356,7 @@ function PipelineOverviewPage() {
isLoading={isFetching}
filters={filters}
onClearFilters={onClearFilters}
sortKey={currentSortKey.key}
/>
</PageSection>
</>