diff --git a/.vscode/settings.json b/.vscode/settings.json index c824ea4edb783..bfd222ce6fe2c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -155,6 +155,7 @@ "typesafe", "unconvert", "Untar", + "upsert", "Userspace", "VMID", "walkthrough", diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 2d0485b8f347b..a2676aa84bf98 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -30,11 +30,10 @@ export const templateByName = ( }; }; -const getTemplatesQueryKey = (organizationId: string, deprecated?: boolean) => [ - organizationId, - "templates", - deprecated, -]; +export const getTemplatesQueryKey = ( + organizationId: string, + deprecated?: boolean, +) => [organizationId, "templates", deprecated]; export const templates = (organizationId: string, deprecated?: boolean) => { return { diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index cf70038e7ca23..9f96a83dc4819 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -124,7 +124,7 @@ export const authMethods = () => { }; }; -const meKey = ["me"]; +export const meKey = ["me"]; export const me = (metadata: MetadataState) => { return cachedQuery({ diff --git a/site/src/components/Menu/MenuButton.tsx b/site/src/components/Menu/MenuButton.tsx new file mode 100644 index 0000000000000..ad9e35d9f41eb --- /dev/null +++ b/site/src/components/Menu/MenuButton.tsx @@ -0,0 +1,9 @@ +import Button, { type ButtonProps } from "@mui/material/Button"; +import { forwardRef } from "react"; +import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; + +export const MenuButton = forwardRef( + (props, ref) => { + return + + + + {options.map((option) => ( + { + setIsOpen(false); + onSelect(option.value); + }} + > + {option.label} + + ))} + + { + setIsOpen(false); + }} + > + Learn advanced filtering + + + + + + + + ); + }} + + ); +}; diff --git a/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.stories.tsx b/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.stories.tsx new file mode 100644 index 0000000000000..d78580fa7b929 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import { useState } from "react"; +import { StatusMenu } from "./StatusMenu"; + +const meta: Meta = { + title: "pages/WorkspacesPage/StatusMenu", + component: StatusMenu, +}; + +export default meta; +type Story = StoryObj; + +export const Close: Story = {}; + +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select status/i }); + await userEvent.click(button); + }, +}; + +export const Selected: Story = { + args: { + selected: "running", + }, +}; + +export const SelectedOpen: Story = { + args: { + selected: "running", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select status/i }); + await userEvent.click(button); + }, +}; + +export const SelectOption: Story = { + render: function StatusMenuWithState(args) { + const [selected, setSelected] = useState(undefined); + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select status/i }); + await userEvent.click(button); + const option = canvas.getByText("Failed"); + await userEvent.click(option); + }, +}; diff --git a/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.tsx b/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.tsx new file mode 100644 index 0000000000000..5659de6aa63fc --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspaceSearch/StatusMenu.tsx @@ -0,0 +1,111 @@ +import { type Theme, useTheme } from "@emotion/react"; +import MenuItem from "@mui/material/MenuItem"; +import MenuList from "@mui/material/MenuList"; +import type { FC } from "react"; +import { MenuButton } from "components/Menu/MenuButton"; +import { MenuCheck } from "components/Menu/MenuCheck"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; + +type StatusIndicatorProps = { + color: keyof Theme["roles"]; +}; + +const StatusIndicator: FC = ({ color }) => { + const theme = useTheme(); + + return ( +
+ ); +}; + +type StatusOption = Readonly<{ + label: string; + value: string; + indicator: JSX.Element; +}>; + +const options: StatusOption[] = [ + { + label: "Running", + value: "running", + indicator: , + }, + { + label: "Stopped", + value: "stopped", + indicator: , + }, + { + label: "Failed", + value: "failed", + indicator: , + }, + { + label: "Pending", + value: "pending", + indicator: , + }, +]; + +type StatusMenuProps = { + selected: string | undefined; + onSelect: (value: string) => void; +}; + +export const StatusMenu: FC = (props) => { + const { selected, onSelect } = props; + const selectedOption = options.find((option) => option.value === selected); + + return ( + + {({ setIsOpen }) => { + return ( + <> + + + {selectedOption ? selectedOption.label : "All statuses"} + + + + + {options.map((option) => { + const isSelected = option.value === selected; + + return ( + { + setIsOpen(false); + onSelect(option.value); + }} + > + {option.indicator} + {option.label} + + + ); + })} + + + + ); + }} + + ); +}; diff --git a/site/src/pages/WorkspacesPage/WorkspaceSearch/TemplateMenu.stories.tsx b/site/src/pages/WorkspacesPage/WorkspaceSearch/TemplateMenu.stories.tsx new file mode 100644 index 0000000000000..edc6bb37d9706 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspaceSearch/TemplateMenu.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import { useState } from "react"; +import { getTemplatesQueryKey } from "api/queries/templates"; +import type { Template } from "api/typesGenerated"; +import { TemplateMenu } from "./TemplateMenu"; + +const meta: Meta = { + title: "pages/WorkspacesPage/TemplateMenu", + component: TemplateMenu, + args: { + organizationId: "123", + }, + parameters: { + queries: [ + { + key: getTemplatesQueryKey("123"), + data: generateTemplates(50), + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Close: Story = {}; + +export const Open: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + }, +}; + +export const Default: Story = { + args: { + selected: "2", + }, +}; + +export const SelectOption: Story = { + render: function TemplateMenuWithState(args) { + const [selected, setSelected] = useState(undefined); + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + const option = canvas.getByText("Template 4"); + await userEvent.click(option); + }, +}; + +export const SearchStickyOnTop: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + + const content = canvasElement.querySelector(".MuiPaper-root"); + content?.scrollTo(0, content.scrollHeight); + }, +}; + +export const ScrollToSelectedOption: Story = { + args: { + selected: "30", + }, + + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + }, +}; + +export const Filter: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + const filter = canvas.getByLabelText("Search template"); + await userEvent.type(filter, "template23"); + }, +}; + +export const EmptyResults: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + const filter = canvas.getByLabelText("Search template"); + await userEvent.type(filter, "invalid-template"); + }, +}; + +export const FocusOnFirstResultWhenPressArrowDown: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button", { name: /Select template/i }); + await userEvent.click(button); + const filter = canvas.getByLabelText("Search template"); + await userEvent.type(filter, "template1"); + await userEvent.type(filter, "{arrowdown}"); + }, +}; + +function generateTemplates(amount: number): Partial