mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): refactor filter component to be more extendable (#13688)
This commit is contained in:
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type BaseOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+49
-11
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user