diff options
Diffstat (limited to 'webui/src/list/Filter.tsx')
-rw-r--r-- | webui/src/list/Filter.tsx | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/webui/src/list/Filter.tsx b/webui/src/list/Filter.tsx new file mode 100644 index 00000000..30b52de8 --- /dev/null +++ b/webui/src/list/Filter.tsx @@ -0,0 +1,189 @@ +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import { SvgIconProps } from '@material-ui/core/SvgIcon'; +import { makeStyles } from '@material-ui/core/styles'; +import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; +import clsx from 'clsx'; +import { LocationDescriptor } from 'history'; +import React, { useState, useRef } from 'react'; +import { Link } from 'react-router-dom'; + +export type Query = { [key: string]: Array<string> }; + +function parse(query: string): Query { + // TODO: extract the rest of the query? + const params: Query = {}; + + // TODO: support escaping without quotes + const re = /(\w+):([A-Za-z0-9-]+|(["'])(([^\3]|\\.)*)\3)+/g; + let matches; + while ((matches = re.exec(query)) !== null) { + if (!params[matches[1]]) { + params[matches[1]] = []; + } + + let value; + if (matches[4]) { + value = matches[4]; + } else { + value = matches[2]; + } + value = value.replace(/\\(.)/g, '$1'); + params[matches[1]].push(value); + } + return params; +} + +function quote(value: string): string { + const hasSingle = value.includes("'"); + const hasDouble = value.includes('"'); + const hasSpaces = value.includes(' '); + if (!hasSingle && !hasDouble && !hasSpaces) { + return value; + } + + if (!hasDouble) { + return `"${value}"`; + } + + if (!hasSingle) { + return `'${value}'`; + } + + value = value.replace(/"/g, '\\"'); + return `"${value}"`; +} + +function stringify(params: Query): string { + const parts: string[][] = Object.entries(params).map(([key, values]) => { + return values.map(value => `${key}:${quote(value)}`); + }); + return new Array<string>().concat(...parts).join(' '); +} + +const useStyles = makeStyles(theme => ({ + element: { + ...theme.typography.body2, + color: '#444', + padding: theme.spacing(0, 1), + fontWeight: 400, + textDecoration: 'none', + display: 'flex', + background: 'none', + border: 'none', + }, + itemActive: { + fontWeight: 600, + color: '#333', + }, + icon: { + paddingRight: theme.spacing(0.5), + }, +})); + +type DropdownTuple = [string, string]; + +type FilterDropdownProps = { + children: React.ReactNode; + dropdown: DropdownTuple[]; + itemActive: (key: string) => boolean; + icon?: React.ComponentType<SvgIconProps>; + to: (key: string) => LocationDescriptor; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; + +function FilterDropdown({ + children, + dropdown, + itemActive, + icon: Icon, + to, + ...props +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + const buttonRef = useRef<HTMLButtonElement>(null); + const classes = useStyles({ active: false }); + + const content = ( + <> + {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />} + <div>{children}</div> + </> + ); + + return ( + <> + <button + ref={buttonRef} + onClick={() => setOpen(!open)} + className={classes.element} + {...props} + > + {content} + <ArrowDropDown fontSize="small" /> + </button> + <Menu + getContentAnchorEl={null} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + open={open} + onClose={() => setOpen(false)} + anchorEl={buttonRef.current} + > + {dropdown.map(([key, value]) => ( + <MenuItem + component={Link} + to={to(key)} + className={itemActive(key) ? classes.itemActive : undefined} + onClick={() => setOpen(false)} + key={key} + > + {value} + </MenuItem> + ))} + </Menu> + </> + ); +} + +export type FilterProps = { + active: boolean; + to: LocationDescriptor; + icon?: React.ComponentType<SvgIconProps>; + children: React.ReactNode; +}; +function Filter({ active, to, children, icon: Icon }: FilterProps) { + const classes = useStyles(); + + const content = ( + <> + {Icon && <Icon fontSize="small" classes={{ root: classes.icon }} />} + <div>{children}</div> + </> + ); + + if (to) { + return ( + <Link + to={to} + className={clsx(classes.element, active && classes.itemActive)} + > + {content} + </Link> + ); + } + + return ( + <div className={clsx(classes.element, active && classes.itemActive)}> + {content} + </div> + ); +} + +export default Filter; +export { parse, stringify, quote, FilterDropdown, Filter }; |