-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Use 'attribute-search' UI pattern for dataset filters : Fixes #1346
- Loading branch information
1 parent
6a6d8b8
commit ab02692
Showing
5 changed files
with
500 additions
and
55 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import React from "react"; | ||
import {Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper} from "@patternfly/react-core"; | ||
import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon"; | ||
|
||
|
||
interface FilterDropDownProps { | ||
options: string[]; | ||
activeAttributeMenu: string; | ||
setActiveAttributeMenu(val: string): any; | ||
} | ||
|
||
export default function FilterDropDown(props: FilterDropDownProps) { | ||
const [isAttributeMenuOpen, setIsAttributeMenuOpen] = React.useState(false); | ||
const attributeToggleRef = React.useRef<HTMLButtonElement>(null); | ||
const attributeMenuRef = React.useRef<HTMLDivElement>(null); | ||
const attributeContainerRef = React.useRef<HTMLDivElement>(null); | ||
|
||
const handleAttribueMenuKeys = (event: KeyboardEvent) => { | ||
if (!isAttributeMenuOpen) { | ||
return; | ||
} | ||
if ( | ||
attributeMenuRef.current?.contains(event.target as Node) || | ||
attributeToggleRef.current?.contains(event.target as Node) | ||
) { | ||
if (event.key === 'Escape' || event.key === 'Tab') { | ||
setIsAttributeMenuOpen(!isAttributeMenuOpen); | ||
attributeToggleRef.current?.focus(); | ||
} | ||
} | ||
}; | ||
|
||
const handleAttributeClickOutside = (event: MouseEvent) => { | ||
if (isAttributeMenuOpen && !attributeMenuRef.current?.contains(event.target as Node)) { | ||
setIsAttributeMenuOpen(false); | ||
} | ||
}; | ||
|
||
React.useEffect(() => { | ||
window.addEventListener('keydown', handleAttribueMenuKeys); | ||
window.addEventListener('click', handleAttributeClickOutside); | ||
return () => { | ||
window.removeEventListener('keydown', handleAttribueMenuKeys); | ||
window.removeEventListener('click', handleAttributeClickOutside); | ||
}; | ||
}, [isAttributeMenuOpen, attributeMenuRef]); | ||
|
||
const onAttributeToggleClick = (ev: React.MouseEvent) => { | ||
ev.stopPropagation(); // Stop handleClickOutside from handling | ||
setTimeout(() => { | ||
if (attributeMenuRef.current) { | ||
const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); | ||
firstElement && (firstElement as HTMLElement).focus(); | ||
} | ||
}, 0); | ||
setIsAttributeMenuOpen(!isAttributeMenuOpen); | ||
}; | ||
|
||
const attributeToggle = ( | ||
<MenuToggle | ||
ref={attributeToggleRef} | ||
onClick={onAttributeToggleClick} | ||
isExpanded={isAttributeMenuOpen} | ||
icon={<FilterIcon />} | ||
> | ||
{props.activeAttributeMenu} | ||
</MenuToggle> | ||
); | ||
const attributeMenu = ( | ||
// eslint-disable-next-line no-console | ||
<Menu | ||
ref={attributeMenuRef} | ||
onSelect={(_ev, itemId) => { | ||
props.setActiveAttributeMenu(itemId?.toString() as string); | ||
setIsAttributeMenuOpen(!isAttributeMenuOpen); | ||
}} | ||
> | ||
<MenuContent> | ||
<MenuList> | ||
{props.options.map((option) => <MenuItem itemId={option}>{option}</MenuItem>)} | ||
</MenuList> | ||
</MenuContent> | ||
</Menu> | ||
); | ||
|
||
return ( | ||
<div ref={attributeContainerRef}> | ||
<Popper | ||
trigger={attributeToggle} | ||
triggerRef={attributeToggleRef} | ||
popper={attributeMenu} | ||
popperRef={attributeMenuRef} | ||
appendTo={attributeContainerRef.current || undefined} | ||
isVisible={isAttributeMenuOpen} | ||
/> | ||
</div> | ||
); | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
import React, { CSSProperties, ReactElement, useEffect, useMemo, useState } from "react" | ||
import { useSelector } from "react-redux" | ||
import { teamsSelector } from "../../auth" | ||
|
||
import { | ||
ToolbarGroup, ToolbarItem, ToolbarToggleGroup | ||
} from '@patternfly/react-core'; | ||
import { | ||
Select, | ||
SelectOption, | ||
SelectOptionObject | ||
} from '@patternfly/react-core/deprecated'; | ||
|
||
import { deepEquals, noop } from "../../utils" | ||
import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon"; | ||
import ToolbarLabelFilter from "../ToolbarLabelFilter"; | ||
import FilterDropDown from "./FilterDropDown"; | ||
|
||
export function convertLabels(obj: any): string { | ||
if (!obj) { | ||
return "" | ||
} else if (Object.keys(obj).length === 0) { | ||
return "<no labels>" | ||
} | ||
let str = "" | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (str !== "") { | ||
str = str + ";" | ||
} | ||
str = str + key + ":" + convertLabelValue(value) | ||
} | ||
return str | ||
} | ||
|
||
function convertLabelValue(value: any) { | ||
if (typeof value === "object") { | ||
// Use the same format as postgres | ||
return JSON.stringify(value).replaceAll(",", ", ").replaceAll(":", ": ") | ||
} | ||
return value | ||
} | ||
|
||
function convertPartial(value: any) { | ||
if (typeof value === "object" && value !== null) { | ||
const copy = Array.isArray(value) ? [...value] : { ...value } | ||
copy.toString = () => convertLabelValue(value) | ||
return copy | ||
} | ||
return value | ||
} | ||
|
||
export type SelectedLabels = SelectOptionObject | null | ||
|
||
type LabelsSelectProps = { | ||
disabled?: boolean | ||
selection?: SelectedLabels | ||
onSelect(selection: SelectedLabels | undefined): void | ||
source(): Promise<any[]> | ||
emptyPlaceholder?: ReactElement | null | ||
style?: CSSProperties | ||
clearCallback( callback: () => void): any; | ||
} | ||
|
||
export default function LabelFilter({selection, onSelect, source, emptyPlaceholder, clearCallback}: LabelsSelectProps) { | ||
|
||
const [availableLabels, setAvailableLabels] = useState<any[]>([]) | ||
|
||
const initialSelect = selection | ||
? Object.entries(selection).reduce((acc, [key, value]) => { | ||
if (key !== "toString") { | ||
acc[key] = convertPartial(value) | ||
} | ||
return acc | ||
}, {} as Record<string, any>) | ||
: {} | ||
const [partialSelect, setPartialSelect] = useState<any>(initialSelect) | ||
|
||
const teams = useSelector(teamsSelector) | ||
useEffect(() => { | ||
source().then((response: any[]) => { | ||
setAvailableLabels(response) | ||
}, noop) | ||
}, [source, onSelect, teams]) | ||
|
||
const options = useMemo(() => { | ||
const opts : any[] = [] | ||
availableLabels.map(t => ({ ...t, toString: () => convertLabels(t) })).forEach(o => opts.push(o)) | ||
return opts | ||
}, [availableLabels]) | ||
|
||
function getFilteredOptions(filter: any) { | ||
return availableLabels.filter(ls => { | ||
for (const [key, value] of Object.entries(filter)) { | ||
if (Array.isArray(value)) { | ||
if (!deepEquals(ls[key], value)) { | ||
return false | ||
} | ||
} else if (typeof value === "object") { | ||
const copy: any = { ...value } | ||
delete copy.toString | ||
if (!deepEquals(ls[key], copy)) { | ||
return false | ||
} | ||
} else if (ls[key] !== value) { | ||
return false | ||
} | ||
} | ||
return true | ||
}) | ||
} | ||
const filteredOptions = useMemo(() => getFilteredOptions(partialSelect), [availableLabels, partialSelect]) | ||
const allOptions = [...new Set(availableLabels.flatMap(ls => Object.keys(ls)))] | ||
|
||
// Set up attribute selector | ||
const [activeAttributeMenu, setActiveAttributeMenu] = React.useState(""); | ||
|
||
useMemo(() => | ||
setActiveAttributeMenu(allOptions[0] || "") | ||
, [availableLabels]); | ||
|
||
const attributeDropdown = <FilterDropDown | ||
options={allOptions} | ||
activeAttributeMenu={activeAttributeMenu} | ||
setActiveAttributeMenu={setActiveAttributeMenu} | ||
/>; | ||
|
||
const empty = !options || options.length === 0 | ||
if (empty) { | ||
return emptyPlaceholder || null | ||
} else { | ||
const items = [...new Set(availableLabels.flatMap(ls => Object.keys(ls)))].map(key => { | ||
const values = filteredOptions.map(fo => fo[key]) | ||
// javascript Set cannot use deep equality comparison | ||
const opts = values | ||
.filter((value, index) => { | ||
for (let i = index + 1; i < values.length; ++i) { | ||
if (deepEquals(value, values[i])) { | ||
return false | ||
} | ||
} | ||
return true | ||
}) | ||
.map(value => convertPartial(value)) | ||
.sort() | ||
|
||
return ( | ||
|
||
<ToolbarLabelFilter | ||
name={key} | ||
options={opts} | ||
filter={selection} | ||
setFilter={onSelect} | ||
activeMenu={activeAttributeMenu} | ||
clearCallback={clearCallback} | ||
/> | ||
) | ||
}) | ||
|
||
return <ToolbarToggleGroup toggleIcon={<FilterIcon />} breakpoint="xl" spacer={{ default: 'spacerLg' }}> | ||
<ToolbarGroup variant="filter-group"> | ||
<ToolbarItem>{attributeDropdown}</ToolbarItem> | ||
{items} | ||
</ToolbarGroup> | ||
</ToolbarToggleGroup> | ||
|
||
} | ||
} | ||
|
111 changes: 111 additions & 0 deletions
111
horreum-web/src/components/LabelFilter/LabelFilterOption.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import React from "react"; | ||
import {Menu, MenuContent, MenuItem, MenuList, MenuToggle, Popper} from "@patternfly/react-core"; | ||
|
||
interface FilterOptionProps { | ||
name: string; | ||
options: string[]; | ||
filter: any; | ||
setFilter(filter: any): any; | ||
selection: string; | ||
setSelection(selection: string): any; | ||
} | ||
|
||
|
||
export default function LabelFilterOption(props: FilterOptionProps) { | ||
const [isMenuOpen, setIsMenuOpen] = React.useState<boolean>(false); | ||
const toggleRef = React.useRef<HTMLButtonElement>(null); | ||
const menuRef = React.useRef<HTMLDivElement>(null); | ||
const containerRef = React.useRef<HTMLDivElement>(null); | ||
|
||
const handleMenuKeys = (event: KeyboardEvent) => { | ||
if (isMenuOpen && menuRef.current?.contains(event.target as Node)) { | ||
if (event.key === 'Escape' || event.key === 'Tab') { | ||
setIsMenuOpen(!isMenuOpen); | ||
toggleRef.current?.focus(); | ||
} | ||
} | ||
}; | ||
|
||
const handleClickOutside = (event: MouseEvent) => { | ||
if (isMenuOpen && !menuRef.current?.contains(event.target as Node)) { | ||
setIsMenuOpen(false); | ||
} | ||
}; | ||
|
||
React.useEffect(() => { | ||
window.addEventListener('keydown', handleMenuKeys); | ||
window.addEventListener('click', handleClickOutside); | ||
return () => { | ||
window.removeEventListener('keydown', handleMenuKeys); | ||
window.removeEventListener('click', handleClickOutside); | ||
}; | ||
}, [isMenuOpen, menuRef]); | ||
|
||
const onToggleClick = (ev: React.MouseEvent) => { | ||
ev.stopPropagation(); // Stop handleClickOutside from handling | ||
setTimeout(() => { | ||
if (menuRef.current) { | ||
const firstElement = menuRef.current.querySelector('li > button:not(:disabled)'); | ||
firstElement && (firstElement as HTMLElement).focus(); | ||
} | ||
}, 0); | ||
setIsMenuOpen(!isMenuOpen); | ||
}; | ||
|
||
function onSelect(event: React.MouseEvent | undefined, itemId: string | number | undefined) { | ||
if (typeof itemId === 'undefined') { | ||
return; | ||
} | ||
|
||
props.setSelection(itemId.toString()); | ||
const newFilter = {...props.filter}; | ||
newFilter[props.name] = itemId; | ||
props.setFilter(newFilter); | ||
setIsMenuOpen(!isMenuOpen); | ||
} | ||
|
||
const toggle = ( | ||
<MenuToggle | ||
ref={toggleRef} | ||
onClick={onToggleClick} | ||
isExpanded={isMenuOpen} | ||
style={ | ||
{ | ||
width: '200px' | ||
} as React.CSSProperties | ||
} | ||
> | ||
Filter by {props.name} | ||
</MenuToggle> | ||
); | ||
|
||
const menu = ( | ||
<Menu ref={menuRef} id="attribute-search-{props.name}-menu" onSelect={onSelect} selected={props.selection}> | ||
<MenuContent> | ||
<MenuList> | ||
{ | ||
props.options.map((option) => ( | ||
<MenuItem isSelected={props.selection === option} itemId={option} key={option}> | ||
{option} | ||
</MenuItem> | ||
)) | ||
} | ||
</MenuList> | ||
</MenuContent> | ||
</Menu> | ||
); | ||
|
||
return ( | ||
<div ref={containerRef}> | ||
<Popper | ||
trigger={toggle} | ||
triggerRef={toggleRef} | ||
popper={menu} | ||
popperRef={menuRef} | ||
appendTo={containerRef.current || undefined} | ||
isVisible={isMenuOpen} | ||
/> | ||
</div> | ||
); | ||
|
||
} |
Oops, something went wrong.