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; -}; pFad - Phonifier reborn

    Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

    Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


    Alternative Proxies:

    Alternative Proxy

    pFad Proxy

    pFad v3 Proxy

    pFad v4 Proxy