Skip to content

Commit

Permalink
Use 'attribute-search' UI pattern for dataset filters : Fixes #1346
Browse files Browse the repository at this point in the history
  • Loading branch information
johnaohara committed Jun 23, 2024
1 parent 6a6d8b8 commit ab02692
Show file tree
Hide file tree
Showing 5 changed files with 500 additions and 55 deletions.
99 changes: 99 additions & 0 deletions horreum-web/src/components/LabelFilter/FilterDropDown.tsx
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>
);

}
168 changes: 168 additions & 0 deletions horreum-web/src/components/LabelFilter/LabelFilter.tsx
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 horreum-web/src/components/LabelFilter/LabelFilterOption.tsx
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>
);

}
Loading

0 comments on commit ab02692

Please sign in to comment.