diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx new file mode 100644 index 0000000000000..c16bd9f8fe339 --- /dev/null +++ b/site/src/components/Filter/filter.tsx @@ -0,0 +1,482 @@ +import { ReactNode, forwardRef, useEffect, useRef, useState } from "react" +import Box from "@mui/material/Box" +import TextField from "@mui/material/TextField" +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown" +import Button, { ButtonProps } from "@mui/material/Button" +import Menu, { MenuProps } from "@mui/material/Menu" +import MenuItem from "@mui/material/MenuItem" +import SearchOutlined from "@mui/icons-material/SearchOutlined" +import InputAdornment from "@mui/material/InputAdornment" +import IconButton from "@mui/material/IconButton" +import Tooltip from "@mui/material/Tooltip" +import CloseOutlined from "@mui/icons-material/CloseOutlined" +import { useSearchParams } from "react-router-dom" +import Skeleton, { SkeletonProps } from "@mui/material/Skeleton" +import CheckOutlined from "@mui/icons-material/CheckOutlined" +import { + getValidationErrorMessage, + hasError, + isApiValidationError, +} from "api/errors" +import { useFilterMenu } from "./menu" +import { BaseOption } from "./options" +import debounce from "just-debounce-it" +import MenuList from "@mui/material/MenuList" +import { Loader } from "components/Loader/Loader" + +type FilterValues = Record + +export const useFilter = ({ + initialValue = "", + onUpdate, + searchParamsResult, +}: { + initialValue?: string + searchParamsResult: ReturnType + onUpdate?: () => void +}) => { + const [searchParams, setSearchParams] = searchParamsResult + const query = searchParams.get("filter") ?? initialValue + const values = parseFilterQuery(query) + + const update = (values: string | FilterValues) => { + if (typeof values === "string") { + searchParams.set("filter", values) + } else { + searchParams.set("filter", stringifyFilter(values)) + } + setSearchParams(searchParams) + if (onUpdate) { + onUpdate() + } + } + + const debounceUpdate = debounce( + (values: string | FilterValues) => update(values), + 500, + ) + + const used = query !== "" && query !== initialValue + + return { + query, + update, + debounceUpdate, + values, + used, + } +} + +const parseFilterQuery = (filterQuery: string): FilterValues => { + if (filterQuery === "") { + return {} + } + + const pairs = filterQuery.split(" ") + const result: FilterValues = {} + + for (const pair of pairs) { + const [key, value] = pair.split(":") as [ + keyof FilterValues, + string | undefined, + ] + if (value) { + result[key] = value + } + } + + return result +} + +const stringifyFilter = (filterValue: FilterValues): string => { + let result = "" + + for (const key in filterValue) { + const value = filterValue[key] + if (value) { + result += `${key}:${value} ` + } + } + + return result.trim() +} + +const BaseSkeleton = (props: SkeletonProps) => { + return ( + theme.palette.background.paperLight, + borderRadius: "6px", + ...props.sx, + }} + /> + ) +} + +export const SearchFieldSkeleton = () => +export const MenuSkeleton = () => ( + +) + +export const Filter = ({ + filter, + isLoading, + error, + skeleton, + options, +}: { + filter: ReturnType + skeleton: ReactNode + isLoading: boolean + error?: unknown + options?: ReactNode +}) => { + const shouldDisplayError = hasError(error) && isApiValidationError(error) + const hasFilterQuery = filter.query !== "" + const [searchQuery, setSearchQuery] = useState(filter.query) + + useEffect(() => { + setSearchQuery(filter.query) + }, [filter.query]) + + return ( + + {isLoading ? ( + skeleton + ) : ( + <> + { + setSearchQuery(e.target.value) + filter.debounceUpdate(e.target.value) + }, + sx: { + borderRadius: "6px", + "& input::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }, + startAdornment: ( + + theme.palette.text.secondary, + }} + /> + + ), + endAdornment: hasFilterQuery && ( + + + { + filter.update("") + }} + > + + + + + ), + }} + /> + + {options} + + )} + + ) +} + +export const FilterMenu = ({ + id, + menu, + label, + children, +}: { + menu: ReturnType> + label: ReactNode + id: string + children: (values: { option: TOption; isSelected: boolean }) => ReactNode +}) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ minWidth: 200 }} + > + {label} + + + {menu.searchOptions?.map((option) => ( + { + menu.selectOption(option) + handleClose() + }} + > + {children({ + option, + isSelected: option.value === menu.selectedOption?.value, + })} + + ))} + +
+ ) +} + +export const FilterSearchMenu = ({ + id, + menu, + label, + children, +}: { + menu: ReturnType> + label: ReactNode + id: string + children: (values: { option: TOption; isSelected: boolean }) => ReactNode +}) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ minWidth: 200 }} + > + {label} + + ( + { + menu.selectOption(option) + handleClose() + }} + > + {children({ + option, + isSelected: option.value === menu.selectedOption?.value, + })} + + )} + /> +
+ ) +} + +type OptionItemProps = { + option: BaseOption + left?: ReactNode + isSelected?: boolean +} + +export const OptionItem = ({ option, left, isSelected }: OptionItemProps) => { + return ( + + {left} + + {option.label} + + {isSelected && ( + + )} + + ) +} + +const MenuButton = forwardRef((props, ref) => { + return ( +