chore(site): refactor filter component to be more extendable (#13688)

This commit is contained in:
Bruno Quaresma
2024-07-02 13:15:13 -03:00
committed by GitHub
parent 21a923a7a0
commit 9ee53e5b4e
20 changed files with 790 additions and 661 deletions
@@ -1,39 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { OptionItem } from "./filter";
const meta: Meta<typeof OptionItem> = {
title: "components/Filter/OptionItem",
component: OptionItem,
decorators: [
(Story) => {
return (
<div style={{ width: "300px" }}>
<Story />
</div>
);
},
],
};
export default meta;
type Story = StoryObj<typeof OptionItem>;
export const Selected: Story = {
args: {
option: {
label: "Success option",
value: "success",
},
isSelected: true,
},
};
export const NotSelected: Story = {
args: {
option: {
label: "Success option",
value: "success",
},
isSelected: false,
},
};
@@ -0,0 +1,146 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within, expect } from "@storybook/test";
import { useState } from "react";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { withDesktopViewport } from "testHelpers/storybook";
import {
SelectFilter,
SelectFilterSearch,
type SelectFilterOption,
} from "./SelectFilter";
const options: SelectFilterOption[] = Array.from({ length: 50 }, (_, i) => ({
startIcon: <UserAvatar username={`username ${i + 1}`} size="xs" />,
label: `Option ${i + 1}`,
value: `option-${i + 1}`,
}));
const meta: Meta<typeof SelectFilter> = {
title: "components/SelectFilter",
component: SelectFilter,
args: {
options,
placeholder: "All options",
},
decorators: [withDesktopViewport],
render: function SelectFilterWithState(args) {
const [selectedOption, setSelectedOption] = useState<
SelectFilterOption | undefined
>(args.selectedOption);
return (
<SelectFilter
{...args}
selectedOption={selectedOption}
onSelect={setSelectedOption}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
export default meta;
type Story = StoryObj<typeof SelectFilter>;
export const Closed: Story = {
play: () => {},
};
export const Open: Story = {};
export const Selected: Story = {
args: {
selectedOption: options[25],
},
};
export const WithSearch: Story = {
args: {
selectedOption: options[25],
selectFilterSearch: (
<SelectFilterSearch
value=""
onChange={action("onSearch")}
placeholder="Search options..."
/>
),
},
};
export const LoadingOptions: Story = {
args: {
options: undefined,
},
};
export const NoOptionsFound: Story = {
args: {
options: [],
},
};
export const SelectingOption: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
const option = canvas.getByText("Option 25");
await userEvent.click(option);
await expect(button).toHaveTextContent("Option 25");
},
};
export const UnselectingOption: Story = {
args: {
selectedOption: options[25],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
const menu = canvasElement.querySelector<HTMLElement>("[role=menu]")!;
const option = within(menu).getByText("Option 26");
await userEvent.click(option);
await expect(button).toHaveTextContent("All options");
},
};
export const SearchingOption: Story = {
render: function SelectFilterWithSearch(args) {
const [selectedOption, setSelectedOption] = useState<
SelectFilterOption | undefined
>(args.selectedOption);
const [search, setSearch] = useState("");
const visibleOptions = options.filter((option) =>
option.value.includes(search),
);
return (
<SelectFilter
{...args}
selectedOption={selectedOption}
onSelect={setSelectedOption}
options={visibleOptions}
selectFilterSearch={
<SelectFilterSearch
value={search}
onChange={setSearch}
placeholder="Search options..."
inputProps={{ "aria-label": "Search options" }}
/>
}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
const search = canvas.getByLabelText("Search options");
await userEvent.type(search, "option-2");
},
};
+116
View File
@@ -0,0 +1,116 @@
import { useState, type FC, type ReactNode } from "react";
import { Loader } from "components/Loader/Loader";
import {
SelectMenu,
SelectMenuTrigger,
SelectMenuButton,
SelectMenuContent,
SelectMenuSearch,
SelectMenuList,
SelectMenuItem,
SelectMenuIcon,
} from "components/SelectMenu/SelectMenu";
const BASE_WIDTH = 200;
const POPOVER_WIDTH = 320;
export type SelectFilterOption = {
startIcon?: ReactNode;
label: string;
value: string;
};
export type SelectFilterProps = {
options: SelectFilterOption[] | undefined;
selectedOption?: SelectFilterOption;
// Used to add a accessibility label to the select
label: string;
// Used when there is no option selected
placeholder: string;
// Used to customize the empty state message
emptyText?: string;
onSelect: (option: SelectFilterOption | undefined) => void;
// SelectFilterSearch element
selectFilterSearch?: ReactNode;
};
export const SelectFilter: FC<SelectFilterProps> = ({
label,
options,
selectedOption,
onSelect,
placeholder,
emptyText,
selectFilterSearch,
}) => {
const [open, setOpen] = useState(false);
return (
<SelectMenu open={open} onOpenChange={setOpen}>
<SelectMenuTrigger>
<SelectMenuButton
startIcon={selectedOption?.startIcon}
css={{ width: BASE_WIDTH }}
aria-label={label}
>
{selectedOption?.label ?? placeholder}
</SelectMenuButton>
</SelectMenuTrigger>
<SelectMenuContent
horizontal="right"
css={{
"& .MuiPaper-root": {
// When including selectFilterSearch, we aim for the width to be as
// wide as possible.
width: selectFilterSearch ? "100%" : undefined,
maxWidth: POPOVER_WIDTH,
minWidth: BASE_WIDTH,
},
}}
>
{selectFilterSearch}
{options ? (
options.length > 0 ? (
<SelectMenuList>
{options.map((o) => {
const isSelected = o.value === selectedOption?.value;
return (
<SelectMenuItem
key={o.value}
selected={isSelected}
onClick={() => {
setOpen(false);
onSelect(isSelected ? undefined : o);
}}
>
{o.startIcon && (
<SelectMenuIcon>{o.startIcon}</SelectMenuIcon>
)}
{o.label}
</SelectMenuItem>
);
})}
</SelectMenuList>
) : (
<div
css={(theme) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 32,
color: theme.palette.text.secondary,
lineHeight: 1,
})}
>
{emptyText || "No options found"}
</div>
)
) : (
<Loader size={16} />
)}
</SelectMenuContent>
</SelectMenu>
);
};
export const SelectFilterSearch = SelectMenuSearch;
+54 -45
View File
@@ -1,29 +1,38 @@
import type { FC } from "react";
import { API } from "api/api";
import {
SelectFilter,
SelectFilterSearch,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { UserAvatar } from "../UserAvatar/UserAvatar";
import { FilterSearchMenu, OptionItem } from "./filter";
import { type UseFilterMenuOptions, useFilterMenu } from "./menu";
import type { BaseOption } from "./options";
export type UserOption = BaseOption & {
avatarUrl?: string;
};
export const useUserFilterMenu = ({
value,
onChange,
enabled,
}: Pick<
UseFilterMenuOptions<UserOption>,
UseFilterMenuOptions<SelectFilterOption>,
"value" | "onChange" | "enabled"
>) => {
const { user: me } = useAuthenticated();
const addMeAsFirstOption = (options: UserOption[]) => {
const addMeAsFirstOption = (options: SelectFilterOption[]) => {
options = options.filter((option) => option.value !== me.username);
return [
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
{
label: me.username,
value: me.username,
startIcon: (
<UserAvatar
username={me.username}
avatarURL={me.avatar_url}
size="xs"
/>
),
},
...options,
];
};
@@ -38,7 +47,13 @@ export const useUserFilterMenu = ({
return {
label: me.username,
value: me.username,
avatarUrl: me.avatar_url,
startIcon: (
<UserAvatar
username={me.username}
avatarURL={me.avatar_url}
size="xs"
/>
),
};
}
@@ -48,17 +63,29 @@ export const useUserFilterMenu = ({
return {
label: firstUser.username,
value: firstUser.username,
avatarUrl: firstUser.avatar_url,
startIcon: (
<UserAvatar
username={firstUser.username}
avatarURL={firstUser.avatar_url}
size="xs"
/>
),
};
}
return null;
},
getOptions: async (query) => {
const usersRes = await API.getUsers({ q: query, limit: 25 });
let options: UserOption[] = usersRes.users.map((user) => ({
let options = usersRes.users.map<SelectFilterOption>((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
startIcon: (
<UserAvatar
username={user.username}
avatarURL={user.avatar_url}
size="xs"
/>
),
}));
options = addMeAsFirstOption(options);
return options;
@@ -74,37 +101,19 @@ interface UserMenuProps {
export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
return (
<FilterSearchMenu
id="users-menu"
menu={menu}
label={
menu.selectedOption ? (
<UserOptionItem option={menu.selectedOption} />
) : (
"All users"
)
}
>
{(itemProps) => <UserOptionItem {...itemProps} />}
</FilterSearchMenu>
);
};
interface UserOptionItemProps {
option: UserOption;
isSelected?: boolean;
}
const UserOptionItem: FC<UserOptionItemProps> = ({ option, isSelected }) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<UserAvatar
username={option.label}
avatarURL={option.avatarUrl}
css={{ width: 16, height: 16, fontSize: 8 }}
<SelectFilter
label="Select user"
placeholder="All users"
emptyText="No users found"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
inputProps={{ "aria-label": "Search user" }}
placeholder="Search user..."
value={menu.query}
onChange={menu.setQuery}
/>
}
/>
+3 -271
View File
@@ -1,21 +1,12 @@
import { useTheme } from "@emotion/react";
import CheckOutlined from "@mui/icons-material/CheckOutlined";
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import Button, { type ButtonProps } from "@mui/material/Button";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Menu, { type MenuProps } from "@mui/material/Menu";
import Menu 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 {
type FC,
type ReactNode,
forwardRef,
useEffect,
useRef,
useState,
} from "react";
import { type FC, type ReactNode, useEffect, useRef, useState } from "react";
import type { useSearchParams } from "react-router-dom";
import {
getValidationErrorMessage,
@@ -23,17 +14,8 @@ import {
isApiValidationError,
} from "api/errors";
import { InputGroup } from "components/InputGroup/InputGroup";
import { Loader } from "components/Loader/Loader";
import {
Search,
SearchEmpty,
SearchInput,
searchStyles,
} from "components/Search/Search";
import { SearchField } from "components/SearchField/SearchField";
import { useDebouncedFunction } from "hooks/debounce";
import type { useFilterMenu } from "./menu";
import type { BaseOption } from "./options";
export type PresetFilter = {
name: string;
@@ -339,253 +321,3 @@ const PresetMenu: FC<PresetMenuProps> = ({
</>
);
};
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>
);
}
+10 -11
View File
@@ -1,8 +1,8 @@
import { useMemo, useRef, useState } from "react";
import { useQuery } from "react-query";
import type { BaseOption } from "./options";
import type { SelectFilterOption } from "components/Filter/SelectFilter";
export type UseFilterMenuOptions<TOption extends BaseOption> = {
export type UseFilterMenuOptions<TOption extends SelectFilterOption> = {
id: string;
value: string | undefined;
// Using null because of react-query
@@ -13,7 +13,9 @@ export type UseFilterMenuOptions<TOption extends BaseOption> = {
enabled?: boolean;
};
export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
export const useFilterMenu = <
TOption extends SelectFilterOption = SelectFilterOption,
>({
id,
value,
getSelectedOption,
@@ -78,16 +80,13 @@ export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
selectedOption,
]);
const selectOption = (option: TOption) => {
let newSelectedOptionValue: TOption | undefined = option;
selectedOptionsCacheRef.current[option.value] = option;
setQuery("");
if (option.value === selectedOption?.value) {
newSelectedOptionValue = undefined;
const selectOption = (option: TOption | undefined) => {
if (option) {
selectedOptionsCacheRef.current[option.value] = option;
}
onChange(newSelectedOptionValue);
setQuery("");
onChange(option);
};
return {
-4
View File
@@ -1,4 +0,0 @@
export type BaseOption = {
label: string;
value: string;
};
+23
View File
@@ -0,0 +1,23 @@
import type { FC } from "react";
import {
SearchField,
type SearchFieldProps,
} from "components/SearchField/SearchField";
export const MenuSearch: FC<SearchFieldProps> = (props) => {
return (
<SearchField
fullWidth
css={(theme) => ({
"& fieldset": {
border: 0,
borderRadius: 0,
// MUI has so many nested selectors that it's easier to just
// override the border directly using the `!important` hack
borderBottom: `1px solid ${theme.palette.divider} !important`,
},
})}
{...props}
/>
);
};
@@ -29,7 +29,7 @@ export const SearchField: FC<SearchFieldProps> = ({
<InputAdornment position="start">
<SearchIcon
css={{
fontSize: 14,
fontSize: 16,
color: theme.palette.text.secondary,
}}
/>
@@ -0,0 +1,133 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { withDesktopViewport } from "testHelpers/storybook";
import {
SelectMenu,
SelectMenuButton,
SelectMenuContent,
SelectMenuIcon,
SelectMenuItem,
SelectMenuList,
SelectMenuSearch,
SelectMenuTrigger,
} from "./SelectMenu";
const meta: Meta<typeof SelectMenu> = {
title: "components/SelectMenu",
component: SelectMenu,
render: function SelectMenuRender() {
const opts = options(50);
const selectedOpt = opts[20];
return (
<SelectMenu>
<SelectMenuTrigger>
<SelectMenuButton
startIcon={<UserAvatar size="xs" username={selectedOpt} />}
>
{selectedOpt}
</SelectMenuButton>
</SelectMenuTrigger>
<SelectMenuContent>
<SelectMenuSearch onChange={() => {}} />
<SelectMenuList>
{opts.map((o) => (
<SelectMenuItem key={o} selected={o === selectedOpt}>
<SelectMenuIcon>
<UserAvatar size="xs" username={o} />
</SelectMenuIcon>
{o}
</SelectMenuItem>
))}
</SelectMenuList>
</SelectMenuContent>
</SelectMenu>
);
},
decorators: [withDesktopViewport],
};
function options(n: number): string[] {
return Array.from({ length: n }, (_, i) => `Item ${i + 1}`);
}
export default meta;
type Story = StoryObj<typeof SelectMenu>;
export const Closed: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
export const LongButtonText: Story = {
render: function SelectMenuRender() {
const longOption = "Very long text that should be truncated";
const opts = [...options(50), longOption];
const selectedOpt = longOption;
return (
<SelectMenu>
<SelectMenuTrigger>
<SelectMenuButton
css={{ width: 200 }}
startIcon={<UserAvatar size="xs" username={selectedOpt} />}
>
{selectedOpt}
</SelectMenuButton>
</SelectMenuTrigger>
<SelectMenuContent>
<SelectMenuSearch onChange={() => {}} />
<SelectMenuList>
{opts.map((o) => (
<SelectMenuItem key={o} selected={o === selectedOpt}>
<SelectMenuIcon>
<UserAvatar size="xs" username={o} />
</SelectMenuIcon>
{o}
</SelectMenuItem>
))}
</SelectMenuList>
</SelectMenuContent>
</SelectMenu>
);
},
};
export const NoSelectedOption: Story = {
render: function SelectMenuRender() {
const opts = options(50);
return (
<SelectMenu>
<SelectMenuTrigger>
<SelectMenuButton css={{ width: 200 }}>All users</SelectMenuButton>
</SelectMenuTrigger>
<SelectMenuContent>
<SelectMenuSearch onChange={action("search")} />
<SelectMenuList>
{opts.map((o) => (
<SelectMenuItem key={o}>
<SelectMenuIcon>
<UserAvatar size="xs" username={o} />
</SelectMenuIcon>
{o}
</SelectMenuItem>
))}
</SelectMenuList>
</SelectMenuContent>
</SelectMenu>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole("button");
await userEvent.click(button);
},
};
@@ -0,0 +1,155 @@
import CheckOutlined from "@mui/icons-material/CheckOutlined";
import Button, { type ButtonProps } from "@mui/material/Button";
import MenuItem, { type MenuItemProps } from "@mui/material/MenuItem";
import MenuList, { type MenuListProps } from "@mui/material/MenuList";
import {
type FC,
forwardRef,
Children,
isValidElement,
type HTMLProps,
type ReactElement,
useMemo,
} from "react";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import {
SearchField,
type SearchFieldProps,
} from "components/SearchField/SearchField";
const SIDE_PADDING = 16;
export const SelectMenu = Popover;
export const SelectMenuTrigger = PopoverTrigger;
export const SelectMenuContent = PopoverContent;
export const SelectMenuButton = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
return (
<Button
css={{
// Icon and text should be aligned to the left
justifyContent: "flex-start",
flexShrink: 0,
"& .MuiButton-startIcon": {
marginLeft: 0,
marginRight: SIDE_PADDING,
},
// Dropdown arrow should be at the end of the button
"& .MuiButton-endIcon": {
marginLeft: "auto",
},
}}
endIcon={<DropdownArrow />}
ref={ref}
{...props}
// MUI applies a style that affects the sizes of start icons.
// .MuiButton-startIcon > *:nth-of-type(1) { font-size: 20px }. To
// prevent this from breaking the inner components of startIcon, we wrap
// it in a div.
startIcon={props.startIcon && <div>{props.startIcon}</div>}
>
<span
// Make sure long text does not break the button layout
css={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{props.children}
</span>
</Button>
);
},
);
export const SelectMenuSearch: FC<SearchFieldProps> = (props) => {
return (
<SearchField
fullWidth
size="medium"
css={(theme) => ({
borderBottom: `1px solid ${theme.palette.divider}`,
"& input": {
fontSize: 14,
},
"& fieldset": {
border: 0,
borderRadius: 0,
},
"& .MuiInputBase-root": {
padding: `12px ${SIDE_PADDING}px`,
},
"& .MuiInputAdornment-positionStart": {
marginRight: SIDE_PADDING,
},
})}
{...props}
inputProps={{ autoFocus: true, ...props.inputProps }}
/>
);
};
export const SelectMenuList: FC<MenuListProps> = (props) => {
const items = useMemo(() => {
let children = Children.toArray(props.children);
if (!children.every(isValidElement)) {
throw new Error("SelectMenuList only accepts MenuItem children");
}
children = moveSelectedElementToFirst(
children as ReactElement<MenuItemProps>[],
);
return children;
}, [props.children]);
return (
<MenuList css={{ maxHeight: 480 }} {...props}>
{items}
</MenuList>
);
};
function moveSelectedElementToFirst(items: ReactElement<MenuItemProps>[]) {
const selectedElement = items.find((i) => i.props.selected);
if (!selectedElement) {
return items;
}
const selectedElementIndex = items.indexOf(selectedElement);
const newItems = items.slice();
newItems.splice(selectedElementIndex, 1);
newItems.unshift(selectedElement);
return newItems;
}
export const SelectMenuIcon: FC<HTMLProps<HTMLDivElement>> = (props) => {
return <div css={{ marginRight: 16 }} {...props} />;
};
export const SelectMenuItem: FC<MenuItemProps> = (props) => {
return (
<MenuItem
css={{
fontSize: 14,
gap: 0,
lineHeight: 1,
padding: `12px ${SIDE_PADDING}px`,
}}
{...props}
>
{props.children}
{props.selected && (
<CheckOutlined
// TODO: Don't set the menu icon font size on default theme
css={{ marginLeft: "auto", fontSize: "inherit !important" }}
/>
)}
</MenuItem>
);
};
@@ -0,0 +1,22 @@
import { useTheme } from "@emotion/react";
import type { FC } from "react";
import type { ThemeRole } from "theme/roles";
interface StatusIndicatorProps {
color: ThemeRole;
}
export const StatusIndicator: FC<StatusIndicatorProps> = ({ color }) => {
const theme = useTheme();
return (
<div
css={{
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: theme.roles[color].fill.solid,
}}
/>
);
};
@@ -0,0 +1,18 @@
import type { FC } from "react";
import type { Template } from "api/typesGenerated";
import { Avatar, type AvatarProps } from "components/Avatar/Avatar";
interface TemplateAvatarProps extends AvatarProps {
template: Template;
}
export const TemplateAvatar: FC<TemplateAvatarProps> = ({
template,
...avatarProps
}) => {
return template.icon ? (
<Avatar src={template.icon} variant="square" fitImage {...avatarProps} />
) : (
<Avatar {...avatarProps}>{template.display_name || template.name}</Avatar>
);
};
+22 -33
View File
@@ -3,9 +3,7 @@ import type { FC } from "react";
import { AuditActions, ResourceTypes } from "api/typesGenerated";
import {
Filter,
FilterMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
type useFilter,
} from "components/Filter/filter";
@@ -13,7 +11,10 @@ import {
useFilterMenu,
type UseFilterMenuOptions,
} from "components/Filter/menu";
import type { BaseOption } from "components/Filter/options";
import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import { docs } from "utils/docs";
@@ -74,8 +75,8 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
export const useActionFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
const actionOptions: BaseOption[] = AuditActions.map((action) => ({
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
const actionOptions: SelectFilterOption[] = AuditActions.map((action) => ({
value: action,
label: capitalize(action),
}));
@@ -93,27 +94,21 @@ export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>;
const ActionMenu = (menu: ActionFilterMenu) => {
return (
<FilterMenu
id="action-menu"
menu={menu}
label={
menu.selectedOption ? (
<OptionItem option={menu.selectedOption} />
) : (
"All actions"
)
}
>
{(itemProps) => <OptionItem {...itemProps} />}
</FilterMenu>
<SelectFilter
label="Select an action"
placeholder="All actions"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
/>
);
};
export const useResourceTypeFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
const actionOptions: BaseOption[] = ResourceTypes.map((type) => {
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
const actionOptions: SelectFilterOption[] = ResourceTypes.map((type) => {
let label = capitalize(type);
if (type === "api_key") {
@@ -153,18 +148,12 @@ export type ResourceTypeFilterMenu = ReturnType<
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
return (
<FilterMenu
id="resource-type-menu"
menu={menu}
label={
menu.selectedOption ? (
<OptionItem option={menu.selectedOption} />
) : (
"All resource types"
)
}
>
{(itemProps) => <OptionItem {...itemProps} />}
</FilterMenu>
<SelectFilter
label="Select a resource type"
placeholder="All resource types"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
/>
);
};
+28 -63
View File
@@ -1,10 +1,7 @@
import { useTheme } from "@emotion/react";
import type { FC } from "react";
import {
Filter,
FilterMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
type useFilter,
} from "components/Filter/filter";
@@ -12,8 +9,11 @@ import {
type UseFilterMenuOptions,
useFilterMenu,
} from "components/Filter/menu";
import type { BaseOption } from "components/Filter/options";
import type { ThemeRole } from "theme/roles";
import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
import { docs } from "utils/docs";
const userFilterQuery = {
@@ -21,18 +21,26 @@ const userFilterQuery = {
all: "",
};
type StatusOption = BaseOption & {
color: ThemeRole;
};
export const useStatusFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
const statusOptions: StatusOption[] = [
{ value: "active", label: "Active", color: "success" },
{ value: "dormant", label: "Dormant", color: "notice" },
{ value: "suspended", label: "Suspended", color: "warning" },
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
const statusOptions: SelectFilterOption[] = [
{
value: "active",
label: "Active",
startIcon: <StatusIndicator color="success" />,
},
{
value: "dormant",
label: "Dormant",
startIcon: <StatusIndicator color="notice" />,
},
{
value: "suspended",
label: "Suspended",
startIcon: <StatusIndicator color="warning" />,
},
];
return useFilterMenu({
onChange,
@@ -82,55 +90,12 @@ export const UsersFilter: FC<UsersFilterProps> = ({ filter, error, menus }) => {
const StatusMenu = (menu: StatusFilterMenu) => {
return (
<FilterMenu
id="status-menu"
menu={menu}
label={
menu.selectedOption ? (
<StatusOptionItem option={menu.selectedOption} />
) : (
"All statuses"
)
}
>
{(itemProps) => <StatusOptionItem {...itemProps} />}
</FilterMenu>
);
};
interface StatusOptionItemProps {
option: StatusOption;
isSelected?: boolean;
}
const StatusOptionItem: FC<StatusOptionItemProps> = ({
option,
isSelected,
}) => {
return (
<OptionItem
option={option}
left={<StatusIndicator option={option} />}
isSelected={isSelected}
/>
);
};
interface StatusIndicatorProps {
option: StatusOption;
}
const StatusIndicator: FC<StatusIndicatorProps> = ({ option }) => {
const theme = useTheme();
return (
<div
css={{
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: theme.roles[option.color].fill.solid,
}}
<SelectFilter
label="Select a status"
placeholder="All statuses"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
/>
);
};
@@ -11,6 +11,7 @@ import {
import type { Template } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Loader } from "components/Loader/Loader";
import { MenuSearch } from "components/Menu/MenuSearch";
import { OverflowY } from "components/OverflowY/OverflowY";
import {
Popover,
@@ -18,7 +19,6 @@ import {
PopoverTrigger,
} from "components/Popover/Popover";
import { SearchEmpty, searchStyles } from "components/Search/Search";
import { SearchBox } from "./WorkspacesSearchBox";
const ICON_SIZE = 18;
@@ -67,12 +67,11 @@ export const WorkspacesButton: FC<WorkspacesButtonProps> = ({
".MuiPaper-root": searchStyles.content,
}}
>
<SearchBox
<MenuSearch
value={searchTerm}
onValueChange={(newValue) => setSearchTerm(newValue)}
onChange={setSearchTerm}
placeholder="Type/select a workspace template"
label="Template select for workspace"
css={{ flexShrink: 0, columnGap: 12 }}
aria-label="Template select for workspace"
/>
<OverflowY
@@ -1,50 +0,0 @@
/**
* @file Defines a controlled searchbox component for processing form state.
*
* Not defined as a top-level component just yet, because it's not clear how
* reusable this is outside of workspace dropdowns.
*/
import {
type FC,
type KeyboardEvent,
type InputHTMLAttributes,
type Ref,
useId,
} from "react";
import { Search, SearchInput } from "components/Search/Search";
interface SearchBoxProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
value: string;
onKeyDown?: (event: KeyboardEvent) => void;
onValueChange: (newValue: string) => void;
$$ref?: Ref<HTMLInputElement>;
}
export const SearchBox: FC<SearchBoxProps> = ({
onValueChange,
onKeyDown,
label = "Search",
placeholder = "Search...",
$$ref,
...attrs
}) => {
const hookId = useId();
const inputId = `${hookId}-${SearchBox.name}-input`;
return (
<Search>
<SearchInput
label={label}
$$ref={$$ref}
id={inputId}
autoFocus
tabIndex={0}
placeholder={placeholder}
{...attrs}
onKeyDown={onKeyDown}
onChange={(e) => onValueChange(e.target.value)}
/>
</Search>
);
};
+6 -118
View File
@@ -1,20 +1,19 @@
import { useTheme } from "@emotion/react";
import type { FC } from "react";
import { Avatar, type AvatarProps } from "components/Avatar/Avatar";
import {
Filter,
FilterMenu,
FilterSearchMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
type useFilter,
} from "components/Filter/filter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import { useDashboard } from "modules/dashboard/useDashboard";
import { docs } from "utils/docs";
import type { TemplateFilterMenu, StatusFilterMenu } from "./menus";
import type { TemplateOption, StatusOption } from "./options";
import {
TemplateMenu,
StatusMenu,
type TemplateFilterMenu,
type StatusFilterMenu,
} from "./menus";
export const workspaceFilterQuery = {
me: "owner:me",
@@ -109,114 +108,3 @@ export const WorkspacesFilter: FC<WorkspaceFilterProps> = ({
/>
);
};
const TemplateMenu = (menu: TemplateFilterMenu) => {
return (
<FilterSearchMenu
id="templates-menu"
menu={menu}
label={
menu.selectedOption ? (
<TemplateOptionItem option={menu.selectedOption} />
) : (
"All templates"
)
}
>
{(itemProps) => <TemplateOptionItem {...itemProps} />}
</FilterSearchMenu>
);
};
interface TemplateOptionItemProps {
option: TemplateOption;
isSelected?: boolean;
}
const TemplateOptionItem: FC<TemplateOptionItemProps> = ({
option,
isSelected,
}) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<TemplateAvatar
templateName={option.label}
icon={option.icon}
css={{ width: 14, height: 14, fontSize: 8 }}
/>
}
/>
);
};
interface TemplateAvatarProps extends AvatarProps {
templateName: string;
icon?: string;
}
const TemplateAvatar: FC<TemplateAvatarProps> = ({
templateName,
icon,
...avatarProps
}) => {
return icon ? (
<Avatar src={icon} variant="square" fitImage {...avatarProps} />
) : (
<Avatar {...avatarProps}>{templateName}</Avatar>
);
};
const StatusMenu = (menu: StatusFilterMenu) => {
return (
<FilterMenu
id="status-menu"
menu={menu}
label={
menu.selectedOption ? (
<StatusOptionItem option={menu.selectedOption} />
) : (
"All statuses"
)
}
>
{(itemProps) => <StatusOptionItem {...itemProps} />}
</FilterMenu>
);
};
interface StatusOptionItem {
option: StatusOption;
isSelected?: boolean;
}
const StatusOptionItem: FC<StatusOptionItem> = ({ option, isSelected }) => {
return (
<OptionItem
option={option}
left={<StatusIndicator option={option} />}
isSelected={isSelected}
/>
);
};
interface StatusIndicatorProps {
option: StatusOption;
}
const StatusIndicator: FC<StatusIndicatorProps> = ({ option }) => {
const theme = useTheme();
return (
<div
css={{
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: theme.roles[option.color].fill.solid,
}}
/>
);
};
@@ -4,15 +4,21 @@ import {
useFilterMenu,
type UseFilterMenuOptions,
} from "components/Filter/menu";
import {
SelectFilter,
SelectFilterSearch,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
import { TemplateAvatar } from "components/TemplateAvatar/TemplateAvatar";
import { getDisplayWorkspaceStatus } from "utils/workspace";
import type { StatusOption, TemplateOption } from "./options";
export const useTemplateFilterMenu = ({
value,
onChange,
organizationId,
}: { organizationId: string } & Pick<
UseFilterMenuOptions<TemplateOption>,
UseFilterMenuOptions<SelectFilterOption>,
"value" | "onChange"
>) => {
return useFilterMenu({
@@ -25,12 +31,9 @@ export const useTemplateFilterMenu = ({
const template = templates.find((template) => template.name === value);
if (template) {
return {
label:
template.display_name !== ""
? template.display_name
: template.name,
label: template.display_name || template.name,
value: template.name,
icon: template.icon,
startIcon: <TemplateAvatar size="xs" template={template} />,
};
}
return null;
@@ -47,7 +50,7 @@ export const useTemplateFilterMenu = ({
label:
template.display_name !== "" ? template.display_name : template.name,
value: template.name,
icon: template.icon,
startIcon: <TemplateAvatar size="xs" template={template} />,
}));
},
});
@@ -55,10 +58,33 @@ export const useTemplateFilterMenu = ({
export type TemplateFilterMenu = ReturnType<typeof useTemplateFilterMenu>;
export const TemplateMenu = (menu: TemplateFilterMenu) => {
return (
<SelectFilter
label="Select a template"
emptyText="No templates found"
placeholder="All templates"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
inputProps={{ "aria-label": "Search template" }}
placeholder="Search template..."
value={menu.query}
onChange={menu.setQuery}
/>
}
/>
);
};
/** Status Filter Menu */
export const useStatusFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
const statusesToFilter: WorkspaceStatus[] = [
"running",
"stopped",
@@ -70,8 +96,8 @@ export const useStatusFilterMenu = ({
return {
label: display.text,
value: status,
color: display.type ?? "warning",
} as StatusOption;
startIcon: <StatusIndicator color={display.type ?? "warning"} />,
};
});
return useFilterMenu({
onChange,
@@ -84,3 +110,15 @@ export const useStatusFilterMenu = ({
};
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>;
export const StatusMenu = (menu: StatusFilterMenu) => {
return (
<SelectFilter
placeholder="All statuses"
label="Select a status"
options={menu.searchOptions}
selectedOption={menu.selectedOption ?? undefined}
onSelect={menu.selectOption}
/>
);
};
@@ -1,10 +0,0 @@
import type { BaseOption } from "components/Filter/options";
import type { ThemeRole } from "theme/roles";
export type StatusOption = BaseOption & {
color: ThemeRole;
};
export type TemplateOption = BaseOption & {
icon?: string;
};