mirror of https://github.com/coder/coder.git
647 lines
17 KiB
TypeScript
647 lines
17 KiB
TypeScript
import { useTheme } from "@emotion/react";
|
|
import CheckOutlined from "@mui/icons-material/CheckOutlined";
|
|
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
|
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
|
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
|
import Button, { type ButtonProps } from "@mui/material/Button";
|
|
import Divider from "@mui/material/Divider";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import InputAdornment from "@mui/material/InputAdornment";
|
|
import Menu, { type MenuProps } from "@mui/material/Menu";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import MenuList from "@mui/material/MenuList";
|
|
import Skeleton, { type SkeletonProps } from "@mui/material/Skeleton";
|
|
import TextField from "@mui/material/TextField";
|
|
import Tooltip from "@mui/material/Tooltip";
|
|
import {
|
|
type FC,
|
|
type ReactNode,
|
|
forwardRef,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { useSearchParams } from "react-router-dom";
|
|
import {
|
|
getValidationErrorMessage,
|
|
hasError,
|
|
isApiValidationError,
|
|
} from "api/errors";
|
|
import { Loader } from "components/Loader/Loader";
|
|
import {
|
|
Search,
|
|
SearchEmpty,
|
|
SearchInput,
|
|
searchStyles,
|
|
} from "components/Menu/Search";
|
|
import { useDebouncedFunction } from "hooks/debounce";
|
|
import type { useFilterMenu } from "./menu";
|
|
import type { BaseOption } from "./options";
|
|
|
|
export type PresetFilter = {
|
|
name: string;
|
|
query: string;
|
|
};
|
|
|
|
type FilterValues = Record<string, string | undefined>;
|
|
|
|
type UseFilterConfig = {
|
|
/**
|
|
* The fallback value to use in the event that no filter params can be parsed
|
|
* from the search params object. This value is allowed to change on
|
|
* re-renders.
|
|
*/
|
|
fallbackFilter?: string;
|
|
searchParamsResult: ReturnType<typeof useSearchParams>;
|
|
onUpdate?: (newValue: string) => void;
|
|
};
|
|
|
|
export const useFilterParamsKey = "filter";
|
|
|
|
export const useFilter = ({
|
|
fallbackFilter = "",
|
|
searchParamsResult,
|
|
onUpdate,
|
|
}: UseFilterConfig) => {
|
|
const [searchParams, setSearchParams] = searchParamsResult;
|
|
const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter;
|
|
|
|
const update = (newValues: string | FilterValues) => {
|
|
const serialized =
|
|
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
|
|
|
|
searchParams.set(useFilterParamsKey, serialized);
|
|
setSearchParams(searchParams);
|
|
|
|
if (onUpdate !== undefined) {
|
|
onUpdate(serialized);
|
|
}
|
|
};
|
|
|
|
const { debounced: debounceUpdate, cancelDebounce } = useDebouncedFunction(
|
|
update,
|
|
500,
|
|
);
|
|
|
|
return {
|
|
query,
|
|
update,
|
|
debounceUpdate,
|
|
cancelDebounce,
|
|
values: parseFilterQuery(query),
|
|
used: query !== "" && query !== fallbackFilter,
|
|
};
|
|
};
|
|
|
|
export type UseFilterResult = ReturnType<typeof useFilter>;
|
|
|
|
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: FC<SkeletonProps> = ({ children, ...skeletonProps }) => {
|
|
return (
|
|
<Skeleton
|
|
variant="rectangular"
|
|
height={36}
|
|
{...skeletonProps}
|
|
css={(theme) => ({
|
|
backgroundColor: theme.palette.background.paper,
|
|
borderRadius: "6px",
|
|
})}
|
|
>
|
|
{children}
|
|
</Skeleton>
|
|
);
|
|
};
|
|
|
|
export const SearchFieldSkeleton: FC = () => {
|
|
return <BaseSkeleton width="100%" />;
|
|
};
|
|
|
|
export const MenuSkeleton: FC = () => {
|
|
return <BaseSkeleton css={{ minWidth: 200, flexShrink: 0 }} />;
|
|
};
|
|
|
|
type FilterProps = {
|
|
filter: ReturnType<typeof useFilter>;
|
|
skeleton: ReactNode;
|
|
isLoading: boolean;
|
|
learnMoreLink: string;
|
|
learnMoreLabel2?: string;
|
|
learnMoreLink2?: string;
|
|
error?: unknown;
|
|
options?: ReactNode;
|
|
presets: PresetFilter[];
|
|
};
|
|
|
|
export const Filter: FC<FilterProps> = ({
|
|
filter,
|
|
isLoading,
|
|
error,
|
|
skeleton,
|
|
options,
|
|
learnMoreLink,
|
|
learnMoreLabel2,
|
|
learnMoreLink2,
|
|
presets,
|
|
}) => {
|
|
const theme = useTheme();
|
|
// Storing local copy of the filter query so that it can be updated more
|
|
// aggressively without re-renders rippling out to the rest of the app every
|
|
// single time. Exists for performance reasons - not really a good way to
|
|
// remove this; render keys would cause the component to remount too often
|
|
const [queryCopy, setQueryCopy] = useState(filter.query);
|
|
const textboxInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Conditionally re-syncs the parent and local filter queries
|
|
useEffect(() => {
|
|
const hasSelfOrInnerFocus =
|
|
textboxInputRef.current?.contains(document.activeElement) ?? false;
|
|
|
|
// This doesn't address all state sync issues - namely, what happens if the
|
|
// user removes focus just after this synchronizing effect fires. Also need
|
|
// to rely on onBlur behavior as an extra safety measure
|
|
if (!hasSelfOrInnerFocus) {
|
|
setQueryCopy(filter.query);
|
|
}
|
|
}, [filter.query]);
|
|
|
|
const shouldDisplayError = hasError(error) && isApiValidationError(error);
|
|
const hasFilterQuery = filter.query !== "";
|
|
|
|
return (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
gap: 8,
|
|
marginBottom: 16,
|
|
flexWrap: "nowrap",
|
|
|
|
[theme.breakpoints.down("md")]: {
|
|
flexWrap: "wrap",
|
|
},
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
skeleton
|
|
) : (
|
|
<>
|
|
<div css={{ display: "flex", width: "100%" }}>
|
|
<PresetMenu
|
|
onSelect={(query) => filter.update(query)}
|
|
presets={presets}
|
|
learnMoreLink={learnMoreLink}
|
|
learnMoreLabel2={learnMoreLabel2}
|
|
learnMoreLink2={learnMoreLink2}
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
error={shouldDisplayError}
|
|
helperText={
|
|
shouldDisplayError
|
|
? getValidationErrorMessage(error)
|
|
: undefined
|
|
}
|
|
size="small"
|
|
InputProps={{
|
|
"aria-label": "Filter",
|
|
name: "query",
|
|
placeholder: "Search...",
|
|
value: queryCopy,
|
|
ref: textboxInputRef,
|
|
onChange: (e) => {
|
|
setQueryCopy(e.target.value);
|
|
filter.debounceUpdate(e.target.value);
|
|
},
|
|
onBlur: () => {
|
|
if (queryCopy !== filter.query) {
|
|
setQueryCopy(filter.query);
|
|
}
|
|
},
|
|
sx: {
|
|
borderRadius: "6px",
|
|
borderTopLeftRadius: 0,
|
|
borderBottomLeftRadius: 0,
|
|
marginLeft: "-1px",
|
|
"&:hover": {
|
|
zIndex: 2,
|
|
},
|
|
"& input::placeholder": {
|
|
color: theme.palette.text.secondary,
|
|
},
|
|
"& .MuiInputAdornment-root": {
|
|
marginLeft: 0,
|
|
},
|
|
"&.Mui-error": {
|
|
zIndex: 3,
|
|
},
|
|
},
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchOutlined
|
|
css={{
|
|
fontSize: 14,
|
|
color: theme.palette.text.secondary,
|
|
}}
|
|
/>
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: hasFilterQuery && (
|
|
<InputAdornment position="end">
|
|
<Tooltip title="Clear filter">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => {
|
|
filter.update("");
|
|
}}
|
|
>
|
|
<CloseOutlined css={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
</div>
|
|
{options}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface PresetMenuProps {
|
|
presets: PresetFilter[];
|
|
learnMoreLink: string;
|
|
learnMoreLabel2?: string;
|
|
learnMoreLink2?: string;
|
|
onSelect: (query: string) => void;
|
|
}
|
|
|
|
const PresetMenu: FC<PresetMenuProps> = ({
|
|
presets,
|
|
learnMoreLink,
|
|
learnMoreLabel2,
|
|
learnMoreLink2,
|
|
onSelect,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
onClick={() => setIsOpen(true)}
|
|
ref={anchorRef}
|
|
css={{
|
|
borderTopRightRadius: 0,
|
|
borderBottomRightRadius: 0,
|
|
flexShrink: 0,
|
|
zIndex: 1,
|
|
}}
|
|
endIcon={<KeyboardArrowDown />}
|
|
>
|
|
Filters
|
|
</Button>
|
|
<Menu
|
|
id="filter-menu"
|
|
anchorEl={anchorRef.current}
|
|
open={isOpen}
|
|
onClose={() => setIsOpen(false)}
|
|
anchorOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "left",
|
|
}}
|
|
transformOrigin={{
|
|
vertical: "top",
|
|
horizontal: "left",
|
|
}}
|
|
css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }}
|
|
>
|
|
{presets.map((presetFilter) => (
|
|
<MenuItem
|
|
css={{ fontSize: 14 }}
|
|
key={presetFilter.name}
|
|
onClick={() => {
|
|
onSelect(presetFilter.query);
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
{presetFilter.name}
|
|
</MenuItem>
|
|
))}
|
|
<Divider css={{ borderColor: theme.palette.divider }} />
|
|
<MenuItem
|
|
component="a"
|
|
href={learnMoreLink}
|
|
target="_blank"
|
|
css={{ fontSize: 13, fontWeight: 500 }}
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
|
|
View advanced filtering
|
|
</MenuItem>
|
|
{learnMoreLink2 && learnMoreLabel2 && (
|
|
<MenuItem
|
|
component="a"
|
|
href={learnMoreLink2}
|
|
target="_blank"
|
|
css={{ fontSize: 13, fontWeight: 500 }}
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
|
|
{learnMoreLabel2}
|
|
</MenuItem>
|
|
)}
|
|
</Menu>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface FilterMenuProps<TOption extends BaseOption> {
|
|
menu: ReturnType<typeof useFilterMenu<TOption>>;
|
|
label: ReactNode;
|
|
id: string;
|
|
children: (values: { option: TOption; isSelected: boolean }) => ReactNode;
|
|
}
|
|
|
|
export const FilterMenu = <TOption extends BaseOption>(
|
|
props: FilterMenuProps<TOption>,
|
|
) => {
|
|
const { id, menu, label, children } = props;
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
const handleClose = () => {
|
|
setIsMenuOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<MenuButton
|
|
ref={buttonRef}
|
|
onClick={() => setIsMenuOpen(true)}
|
|
css={{ minWidth: 200 }}
|
|
>
|
|
{label}
|
|
</MenuButton>
|
|
<Menu
|
|
id={id}
|
|
anchorEl={buttonRef.current}
|
|
open={isMenuOpen}
|
|
onClose={handleClose}
|
|
css={{ "& .MuiPaper-root": { minWidth: 200 } }}
|
|
// Disabled this so when we clear the filter and do some sorting in the
|
|
// search items it does not look strange. Github removes exit transitions
|
|
// on their filters as well.
|
|
transitionDuration={{
|
|
enter: 250,
|
|
exit: 0,
|
|
}}
|
|
>
|
|
{menu.searchOptions?.map((option) => (
|
|
<MenuItem
|
|
key={option.label}
|
|
selected={option.value === menu.selectedOption?.value}
|
|
onClick={() => {
|
|
menu.selectOption(option);
|
|
handleClose();
|
|
}}
|
|
>
|
|
{children({
|
|
option,
|
|
isSelected: option.value === menu.selectedOption?.value,
|
|
})}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FilterSearchMenuProps<TOption extends BaseOption> {
|
|
menu: ReturnType<typeof useFilterMenu<TOption>>;
|
|
label: ReactNode;
|
|
id: string;
|
|
children: (values: { option: TOption; isSelected: boolean }) => ReactNode;
|
|
}
|
|
|
|
export const FilterSearchMenu = <TOption extends BaseOption>({
|
|
id,
|
|
menu,
|
|
label,
|
|
children,
|
|
}: FilterSearchMenuProps<TOption>) => {
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
|
|
const handleClose = () => {
|
|
setIsMenuOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<MenuButton
|
|
ref={buttonRef}
|
|
onClick={() => setIsMenuOpen(true)}
|
|
css={{ minWidth: 200 }}
|
|
>
|
|
{label}
|
|
</MenuButton>
|
|
<SearchMenu
|
|
id={id}
|
|
anchorEl={buttonRef.current}
|
|
open={isMenuOpen}
|
|
onClose={handleClose}
|
|
options={menu.searchOptions}
|
|
query={menu.query}
|
|
onQueryChange={menu.setQuery}
|
|
renderOption={(option) => (
|
|
<MenuItem
|
|
key={option.value}
|
|
selected={option.value === menu.selectedOption?.value}
|
|
onClick={() => {
|
|
menu.selectOption(option);
|
|
handleClose();
|
|
}}
|
|
>
|
|
{children({
|
|
option,
|
|
isSelected: option.value === menu.selectedOption?.value,
|
|
})}
|
|
</MenuItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type OptionItemProps = {
|
|
option: BaseOption;
|
|
left?: ReactNode;
|
|
isSelected?: boolean;
|
|
};
|
|
|
|
export const OptionItem: FC<OptionItemProps> = ({
|
|
option,
|
|
left,
|
|
isSelected,
|
|
}) => {
|
|
return (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
fontSize: 14,
|
|
overflow: "hidden",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
{left}
|
|
<span css={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
{option.label}
|
|
</span>
|
|
{isSelected && (
|
|
<CheckOutlined css={{ width: 16, height: 16, marginLeft: "auto" }} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MenuButton = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
|
const { children, ...attrs } = props;
|
|
|
|
return (
|
|
<Button
|
|
ref={ref}
|
|
endIcon={<KeyboardArrowDown />}
|
|
css={{
|
|
borderRadius: "6px",
|
|
justifyContent: "space-between",
|
|
lineHeight: "120%",
|
|
}}
|
|
{...attrs}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
});
|
|
|
|
interface SearchMenuProps<TOption extends BaseOption>
|
|
extends Pick<MenuProps, "anchorEl" | "open" | "onClose" | "id"> {
|
|
options?: TOption[];
|
|
renderOption: (option: TOption) => ReactNode;
|
|
query: string;
|
|
onQueryChange: (query: string) => void;
|
|
}
|
|
|
|
function SearchMenu<TOption extends BaseOption>({
|
|
options,
|
|
renderOption,
|
|
query,
|
|
onQueryChange,
|
|
...menuProps
|
|
}: SearchMenuProps<TOption>) {
|
|
const menuListRef = useRef<HTMLUListElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
return (
|
|
<Menu
|
|
{...menuProps}
|
|
onClose={(event, reason) => {
|
|
menuProps.onClose && menuProps.onClose(event, reason);
|
|
onQueryChange("");
|
|
}}
|
|
css={{
|
|
"& .MuiPaper-root": searchStyles.content,
|
|
}}
|
|
// Disabled this so when we clear the filter and do some sorting in the
|
|
// search items it does not look strange. Github removes exit transitions
|
|
// on their filters as well.
|
|
transitionDuration={{
|
|
enter: 250,
|
|
exit: 0,
|
|
}}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "ArrowDown" && menuListRef.current) {
|
|
const firstItem = menuListRef.current.firstChild as HTMLElement;
|
|
firstItem.focus();
|
|
}
|
|
}}
|
|
>
|
|
<Search component="li">
|
|
<SearchInput
|
|
autoFocus
|
|
value={query}
|
|
ref={searchInputRef}
|
|
onChange={(e) => {
|
|
onQueryChange(e.target.value);
|
|
}}
|
|
/>
|
|
</Search>
|
|
|
|
<li css={{ maxHeight: 480, overflowY: "auto" }}>
|
|
<MenuList
|
|
ref={menuListRef}
|
|
onKeyDown={(e) => {
|
|
if (e.shiftKey && e.code === "Tab") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
searchInputRef.current?.focus();
|
|
}
|
|
}}
|
|
>
|
|
{options ? (
|
|
options.length > 0 ? (
|
|
options.map(renderOption)
|
|
) : (
|
|
<SearchEmpty />
|
|
)
|
|
) : (
|
|
<Loader size={14} />
|
|
)}
|
|
</MenuList>
|
|
</li>
|
|
</Menu>
|
|
);
|
|
}
|