diff --git a/site/src/components/Filter/OptionItem.stories.tsx b/site/src/components/Filter/OptionItem.stories.tsx deleted file mode 100644 index d8b223d7b90ed..0000000000000 --- a/site/src/components/Filter/OptionItem.stories.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { OptionItem } from "./filter"; - -const meta: Meta = { - title: "components/Filter/OptionItem", - component: OptionItem, - decorators: [ - (Story) => { - return ( -
- -
- ); - }, - ], -}; - -export default meta; -type Story = StoryObj; - -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, - }, -}; diff --git a/site/src/components/Filter/SelectFilter.stories.tsx b/site/src/components/Filter/SelectFilter.stories.tsx new file mode 100644 index 0000000000000..21d2afe288146 --- /dev/null +++ b/site/src/components/Filter/SelectFilter.stories.tsx @@ -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: , + label: `Option ${i + 1}`, + value: `option-${i + 1}`, +})); + +const meta: Meta = { + 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 ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await userEvent.click(button); + }, +}; + +export default meta; +type Story = StoryObj; + +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: ( + + ), + }, +}; + +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("[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 ( + + } + /> + ); + }, + 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"); + }, +}; diff --git a/site/src/components/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx new file mode 100644 index 0000000000000..7521affc7efb6 --- /dev/null +++ b/site/src/components/Filter/SelectFilter.tsx @@ -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 = ({ + label, + options, + selectedOption, + onSelect, + placeholder, + emptyText, + selectFilterSearch, +}) => { + const [open, setOpen] = useState(false); + + return ( + + + + {selectedOption?.label ?? placeholder} + + + + {selectFilterSearch} + {options ? ( + options.length > 0 ? ( + + {options.map((o) => { + const isSelected = o.value === selectedOption?.value; + return ( + { + setOpen(false); + onSelect(isSelected ? undefined : o); + }} + > + {o.startIcon && ( + {o.startIcon} + )} + {o.label} + + ); + })} + + ) : ( +
({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 32, + color: theme.palette.text.secondary, + lineHeight: 1, + })} + > + {emptyText || "No options found"} +
+ ) + ) : ( + + )} +
+
+ ); +}; + +export const SelectFilterSearch = SelectMenuSearch; diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index a42dbf07d791c..2a69717cb8eaa 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -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, + UseFilterMenuOptions, "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: ( + + ), + }, ...options, ]; }; @@ -38,7 +47,13 @@ export const useUserFilterMenu = ({ return { label: me.username, value: me.username, - avatarUrl: me.avatar_url, + startIcon: ( + + ), }; } @@ -48,17 +63,29 @@ export const useUserFilterMenu = ({ return { label: firstUser.username, value: firstUser.username, - avatarUrl: firstUser.avatar_url, + startIcon: ( + + ), }; } 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((user) => ({ label: user.username, value: user.username, - avatarUrl: user.avatar_url, + startIcon: ( + + ), })); options = addMeAsFirstOption(options); return options; @@ -74,37 +101,19 @@ interface UserMenuProps { export const UserMenu: FC = ({ menu }) => { return ( - - ) : ( - "All users" - ) - } - > - {(itemProps) => } - - ); -}; - -interface UserOptionItemProps { - option: UserOption; - isSelected?: boolean; -} - -const UserOptionItem: FC = ({ option, isSelected }) => { - return ( - } /> diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 29fb34ee4c251..b26ce444a805f 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -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 = ({ ); }; - -interface FilterMenuProps { - menu: ReturnType>; - label: ReactNode; - id: string; - children: (values: { option: TOption; isSelected: boolean }) => ReactNode; -} - -export const FilterMenu = ( - props: FilterMenuProps, -) => { - const { id, menu, label, children } = props; - const buttonRef = useRef(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const handleClose = () => { - setIsMenuOpen(false); - }; - - return ( -
- setIsMenuOpen(true)} - css={{ minWidth: 200 }} - > - {label} - - - {menu.searchOptions?.map((option) => ( - { - menu.selectOption(option); - handleClose(); - }} - > - {children({ - option, - isSelected: option.value === menu.selectedOption?.value, - })} - - ))} - -
- ); -}; - -interface FilterSearchMenuProps { - menu: ReturnType>; - label: ReactNode; - id: string; - children: (values: { option: TOption; isSelected: boolean }) => ReactNode; -} - -export const FilterSearchMenu = ({ - id, - menu, - label, - children, -}: FilterSearchMenuProps) => { - const buttonRef = useRef(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const handleClose = () => { - setIsMenuOpen(false); - }; - - return ( -
- setIsMenuOpen(true)} - css={{ minWidth: 200 }} - > - {label} - - ( - { - menu.selectOption(option); - handleClose(); - }} - > - {children({ - option, - isSelected: option.value === menu.selectedOption?.value, - })} - - )} - /> -
- ); -}; - -type OptionItemProps = { - option: BaseOption; - left?: ReactNode; - isSelected?: boolean; -}; - -export const OptionItem: FC = ({ - option, - left, - isSelected, -}) => { - return ( -
- {left} - - {option.label} - - {isSelected && ( - - )} -
- ); -}; - -const MenuButton = forwardRef((props, ref) => { - const { children, ...attrs } = props; - - return ( - - ); -}); - -interface SearchMenuProps - extends Pick { - options?: TOption[]; - renderOption: (option: TOption) => ReactNode; - query: string; - onQueryChange: (query: string) => void; -} - -function SearchMenu({ - options, - renderOption, - query, - onQueryChange, - ...menuProps -}: SearchMenuProps) { - const menuListRef = useRef(null); - const searchInputRef = useRef(null); - - return ( - { - 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(); - } - }} - > - - { - onQueryChange(e.target.value); - }} - /> - - -
  • - { - if (e.shiftKey && e.code === "Tab") { - e.preventDefault(); - e.stopPropagation(); - searchInputRef.current?.focus(); - } - }} - > - {options ? ( - options.length > 0 ? ( - options.map(renderOption) - ) : ( - - ) - ) : ( - - )} - -
  • -
    - ); -} diff --git a/site/src/components/Filter/menu.ts b/site/src/components/Filter/menu.ts index 21cfec33ad3cc..3075fb6075fa6 100644 --- a/site/src/components/Filter/menu.ts +++ b/site/src/components/Filter/menu.ts @@ -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 = { +export type UseFilterMenuOptions = { id: string; value: string | undefined; // Using null because of react-query @@ -13,7 +13,9 @@ export type UseFilterMenuOptions = { enabled?: boolean; }; -export const useFilterMenu = ({ +export const useFilterMenu = < + TOption extends SelectFilterOption = SelectFilterOption, +>({ id, value, getSelectedOption, @@ -78,16 +80,13 @@ export const useFilterMenu = ({ 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 { diff --git a/site/src/components/Filter/options.ts b/site/src/components/Filter/options.ts deleted file mode 100644 index 08b71deb88a3a..0000000000000 --- a/site/src/components/Filter/options.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type BaseOption = { - label: string; - value: string; -}; diff --git a/site/src/components/Menu/MenuSearch.tsx b/site/src/components/Menu/MenuSearch.tsx new file mode 100644 index 0000000000000..32f8cab9f4a8f --- /dev/null +++ b/site/src/components/Menu/MenuSearch.tsx @@ -0,0 +1,23 @@ +import type { FC } from "react"; +import { + SearchField, + type SearchFieldProps, +} from "components/SearchField/SearchField"; + +export const MenuSearch: FC = (props) => { + return ( + ({ + "& 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} + /> + ); +}; diff --git a/site/src/components/SearchField/SearchField.tsx b/site/src/components/SearchField/SearchField.tsx index 9e81b74e972ac..2f2c65001df22 100644 --- a/site/src/components/SearchField/SearchField.tsx +++ b/site/src/components/SearchField/SearchField.tsx @@ -29,7 +29,7 @@ export const SearchField: FC = ({ diff --git a/site/src/components/SelectMenu/SelectMenu.stories.tsx b/site/src/components/SelectMenu/SelectMenu.stories.tsx new file mode 100644 index 0000000000000..86b33ae817969 --- /dev/null +++ b/site/src/components/SelectMenu/SelectMenu.stories.tsx @@ -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 = { + title: "components/SelectMenu", + component: SelectMenu, + render: function SelectMenuRender() { + const opts = options(50); + const selectedOpt = opts[20]; + + return ( + + + } + > + {selectedOpt} + + + + {}} /> + + {opts.map((o) => ( + + + + + {o} + + ))} + + + + ); + }, + decorators: [withDesktopViewport], +}; + +function options(n: number): string[] { + return Array.from({ length: n }, (_, i) => `Item ${i + 1}`); +} + +export default meta; +type Story = StoryObj; + +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 ( + + + } + > + {selectedOpt} + + + + {}} /> + + {opts.map((o) => ( + + + + + {o} + + ))} + + + + ); + }, +}; + +export const NoSelectedOption: Story = { + render: function SelectMenuRender() { + const opts = options(50); + + return ( + + + All users + + + + + {opts.map((o) => ( + + + + + {o} + + ))} + + + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await userEvent.click(button); + }, +}; diff --git a/site/src/components/SelectMenu/SelectMenu.tsx b/site/src/components/SelectMenu/SelectMenu.tsx new file mode 100644 index 0000000000000..39837720d0023 --- /dev/null +++ b/site/src/components/SelectMenu/SelectMenu.tsx @@ -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( + (props, ref) => { + return ( + + ); + }, +); + +export const SelectMenuSearch: FC = (props) => { + return ( + ({ + 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 = (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[], + ); + return children; + }, [props.children]); + return ( + + {items} + + ); +}; + +function moveSelectedElementToFirst(items: ReactElement[]) { + 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> = (props) => { + return
    ; +}; + +export const SelectMenuItem: FC = (props) => { + return ( + + {props.children} + {props.selected && ( + + )} + + ); +}; diff --git a/site/src/components/StatusIndicator/StatusIndicator.tsx b/site/src/components/StatusIndicator/StatusIndicator.tsx new file mode 100644 index 0000000000000..572ecb017e945 --- /dev/null +++ b/site/src/components/StatusIndicator/StatusIndicator.tsx @@ -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 = ({ color }) => { + const theme = useTheme(); + + return ( +
    + ); +}; diff --git a/site/src/components/TemplateAvatar/TemplateAvatar.tsx b/site/src/components/TemplateAvatar/TemplateAvatar.tsx new file mode 100644 index 0000000000000..49aa7fbb02e10 --- /dev/null +++ b/site/src/components/TemplateAvatar/TemplateAvatar.tsx @@ -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 = ({ + template, + ...avatarProps +}) => { + return template.icon ? ( + + ) : ( + {template.display_name || template.name} + ); +}; diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index e02100e11e448..0127637a4b69d 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -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 = ({ filter, error, menus }) => { export const useActionFilterMenu = ({ value, onChange, -}: Pick, "value" | "onChange">) => { - const actionOptions: BaseOption[] = AuditActions.map((action) => ({ +}: Pick, "value" | "onChange">) => { + const actionOptions: SelectFilterOption[] = AuditActions.map((action) => ({ value: action, label: capitalize(action), })); @@ -93,27 +94,21 @@ export type ActionFilterMenu = ReturnType; const ActionMenu = (menu: ActionFilterMenu) => { return ( - - ) : ( - "All actions" - ) - } - > - {(itemProps) => } - + ); }; export const useResourceTypeFilterMenu = ({ value, onChange, -}: Pick, "value" | "onChange">) => { - const actionOptions: BaseOption[] = ResourceTypes.map((type) => { +}: Pick, "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 ( - - ) : ( - "All resource types" - ) - } - > - {(itemProps) => } - + ); }; diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 45af4103685b5..fdfc2144f5b59 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -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, "value" | "onChange">) => { - const statusOptions: StatusOption[] = [ - { value: "active", label: "Active", color: "success" }, - { value: "dormant", label: "Dormant", color: "notice" }, - { value: "suspended", label: "Suspended", color: "warning" }, +}: Pick, "value" | "onChange">) => { + const statusOptions: SelectFilterOption[] = [ + { + value: "active", + label: "Active", + startIcon: , + }, + { + value: "dormant", + label: "Dormant", + startIcon: , + }, + { + value: "suspended", + label: "Suspended", + startIcon: , + }, ]; return useFilterMenu({ onChange, @@ -82,55 +90,12 @@ export const UsersFilter: FC = ({ filter, error, menus }) => { const StatusMenu = (menu: StatusFilterMenu) => { return ( - - ) : ( - "All statuses" - ) - } - > - {(itemProps) => } - - ); -}; - -interface StatusOptionItemProps { - option: StatusOption; - isSelected?: boolean; -} - -const StatusOptionItem: FC = ({ - option, - isSelected, -}) => { - return ( - } - isSelected={isSelected} - /> - ); -}; - -interface StatusIndicatorProps { - option: StatusOption; -} - -const StatusIndicator: FC = ({ option }) => { - const theme = useTheme(); - - return ( -
    ); }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesButton.tsx b/site/src/pages/WorkspacesPage/WorkspacesButton.tsx index aef8f7518331a..95df46d32316f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesButton.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesButton.tsx @@ -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 = ({ ".MuiPaper-root": searchStyles.content, }} > - 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" /> { - label?: string; - value: string; - onKeyDown?: (event: KeyboardEvent) => void; - onValueChange: (newValue: string) => void; - $$ref?: Ref; -} - -export const SearchBox: FC = ({ - onValueChange, - onKeyDown, - label = "Search", - placeholder = "Search...", - $$ref, - ...attrs -}) => { - const hookId = useId(); - const inputId = `${hookId}-${SearchBox.name}-input`; - - return ( - - onValueChange(e.target.value)} - /> - - ); -}; diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 74b534d5d5d6b..da1066714ae26 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -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 = ({ /> ); }; - -const TemplateMenu = (menu: TemplateFilterMenu) => { - return ( - - ) : ( - "All templates" - ) - } - > - {(itemProps) => } - - ); -}; - -interface TemplateOptionItemProps { - option: TemplateOption; - isSelected?: boolean; -} - -const TemplateOptionItem: FC = ({ - option, - isSelected, -}) => { - return ( - - } - /> - ); -}; - -interface TemplateAvatarProps extends AvatarProps { - templateName: string; - icon?: string; -} - -const TemplateAvatar: FC = ({ - templateName, - icon, - ...avatarProps -}) => { - return icon ? ( - - ) : ( - {templateName} - ); -}; - -const StatusMenu = (menu: StatusFilterMenu) => { - return ( - - ) : ( - "All statuses" - ) - } - > - {(itemProps) => } - - ); -}; - -interface StatusOptionItem { - option: StatusOption; - isSelected?: boolean; -} - -const StatusOptionItem: FC = ({ option, isSelected }) => { - return ( - } - isSelected={isSelected} - /> - ); -}; - -interface StatusIndicatorProps { - option: StatusOption; -} - -const StatusIndicator: FC = ({ option }) => { - const theme = useTheme(); - - return ( -
    - ); -}; diff --git a/site/src/pages/WorkspacesPage/filter/menus.ts b/site/src/pages/WorkspacesPage/filter/menus.tsx similarity index 57% rename from site/src/pages/WorkspacesPage/filter/menus.ts rename to site/src/pages/WorkspacesPage/filter/menus.tsx index f8b6755f50e82..0316f158e87c9 100644 --- a/site/src/pages/WorkspacesPage/filter/menus.ts +++ b/site/src/pages/WorkspacesPage/filter/menus.tsx @@ -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, + UseFilterMenuOptions, "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: , }; } 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: , })); }, }); @@ -55,10 +58,33 @@ export const useTemplateFilterMenu = ({ export type TemplateFilterMenu = ReturnType; +export const TemplateMenu = (menu: TemplateFilterMenu) => { + return ( + + } + /> + ); +}; + +/** Status Filter Menu */ + export const useStatusFilterMenu = ({ value, onChange, -}: Pick, "value" | "onChange">) => { +}: Pick, "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: , + }; }); return useFilterMenu({ onChange, @@ -84,3 +110,15 @@ export const useStatusFilterMenu = ({ }; export type StatusFilterMenu = ReturnType; + +export const StatusMenu = (menu: StatusFilterMenu) => { + return ( + + ); +}; diff --git a/site/src/pages/WorkspacesPage/filter/options.ts b/site/src/pages/WorkspacesPage/filter/options.ts deleted file mode 100644 index 329e5b48612c4..0000000000000 --- a/site/src/pages/WorkspacesPage/filter/options.ts +++ /dev/null @@ -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; -};