diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81675530aa57d..dc606146bc6e6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7551,11 +7551,13 @@ const docTemplate = `{ "type": "string", "enum": [ "moons", - "workspace_actions" + "workspace_actions", + "workspace_filter" ], "x-enum-varnames": [ "ExperimentMoons", - "ExperimentWorkspaceActions" + "ExperimentWorkspaceActions", + "ExperimentWorkspaceFilter" ] }, "codersdk.Feature": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2c9080fe49b74..3f745842ccd67 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6744,8 +6744,12 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["moons", "workspace_actions"], - "x-enum-varnames": ["ExperimentMoons", "ExperimentWorkspaceActions"] + "enum": ["moons", "workspace_actions", "workspace_filter"], + "x-enum-varnames": [ + "ExperimentMoons", + "ExperimentWorkspaceActions", + "ExperimentWorkspaceFilter" + ] }, "codersdk.Feature": { "type": "object", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 84c295aefee7f..54a093f573e86 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1682,6 +1682,9 @@ const ( // https://github.com/coder/coder/milestone/19 ExperimentWorkspaceActions Experiment = "workspace_actions" + // New workspace filter + ExperimentWorkspaceFilter Experiment = "workspace_filter" + // Add new experiments here! // ExperimentExample Experiment = "example" ) diff --git a/docs/api/schemas.md b/docs/api/schemas.md index b613608e555e2..9e2335f468c41 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2559,6 +2559,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | ------------------- | | `moons` | | `workspace_actions` | +| `workspace_filter` | ## codersdk.Feature diff --git a/site/.eslintrc.yaml b/site/.eslintrc.yaml index da75dba53d0a0..f7eb30901c5a0 100644 --- a/site/.eslintrc.yaml +++ b/site/.eslintrc.yaml @@ -136,6 +136,7 @@ rules: "object-curly-spacing": "off" react-hooks/exhaustive-deps: warn react-hooks/rules-of-hooks: error + react/display-name: "off" react/jsx-no-script-url: - error - - name: Link diff --git a/site/package.json b/site/package.json index d70a14f65f3a7..ac753013e3f55 100644 --- a/site/package.json +++ b/site/package.json @@ -111,8 +111,8 @@ "@testing-library/user-event": "14.4.3", "@types/jest": "29.4.0", "@types/node": "14.18.22", - "@types/react": "18.0.15", - "@types/react-dom": "18.0.6", + "@types/react": "18.2.6", + "@types/react-dom": "18.2.4", "@types/react-helmet": "6.1.5", "@types/react-syntax-highlighter": "15.5.5", "@types/react-virtualized-auto-sizer": "1.0.1", diff --git a/site/emoji-mart.d.ts b/site/src/@types/emoji-mart.d.ts similarity index 100% rename from site/emoji-mart.d.ts rename to site/src/@types/emoji-mart.d.ts diff --git a/site/src/@types/i18n.d.ts b/site/src/@types/i18n.d.ts new file mode 100644 index 0000000000000..bee7fa78b1c06 --- /dev/null +++ b/site/src/@types/i18n.d.ts @@ -0,0 +1,10 @@ +import "i18next" + +// https://github.com/i18next/react-i18next/issues/1543#issuecomment-1528679591 +declare module "i18next" { + interface TypeOptions { + returnNull: false + allowObjectInHTMLChildren: false + } + export function t(s: string): T +} diff --git a/site/mui.d.ts b/site/src/@types/mui.d.ts similarity index 100% rename from site/mui.d.ts rename to site/src/@types/mui.d.ts diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 83c41cbcf4dd0..9ff8569f4c632 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -34,6 +34,9 @@ export const isApiValidationError = (error: unknown): error is ApiError => { return isApiError(error) && hasApiFieldErrors(error) } +export const hasError = (error: unknown) => + error !== undefined && error !== null + export const mapApiErrorToFieldErrors = ( apiErrorResponse: ApiErrorResponse, ): FieldErrors => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 768c74f8f640e..d7cc6e67a5930 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1385,8 +1385,12 @@ export const Entitlements: Entitlement[] = [ ] // From codersdk/deployment.go -export type Experiment = "moons" | "workspace_actions" -export const Experiments: Experiment[] = ["moons", "workspace_actions"] +export type Experiment = "moons" | "workspace_actions" | "workspace_filter" +export const Experiments: Experiment[] = [ + "moons", + "workspace_actions", + "workspace_filter", +] // From codersdk/deployment.go export type FeatureName = diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx index 3a52a54acf4e0..a48c8d5946a75 100644 --- a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx @@ -1,7 +1,6 @@ import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated" import { FC, useMemo, useEffect, useState } from "react" import prettyBytes from "pretty-bytes" -import { getStatus } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import BuildingIcon from "@mui/icons-material/Build" import { makeStyles } from "@mui/styles" import { RocketIcon } from "components/Icons/RocketIcon" @@ -19,6 +18,7 @@ import dayjs from "dayjs" import CollectedIcon from "@mui/icons-material/Compare" import RefreshIcon from "@mui/icons-material/Refresh" import Button from "@mui/material/Button" +import { getDisplayWorkspaceStatus } from "utils/workspace" export const bannerHeight = 36 @@ -218,7 +218,7 @@ const WorkspaceBuildValue: FC<{ count?: number }> = ({ status, count }) => { const styles = useStyles() - const displayStatus = getStatus(status) + const displayStatus = getDisplayWorkspaceStatus(status) let statusText = displayStatus.text let icon = displayStatus.icon if (status === "starting") { diff --git a/site/src/components/UserAvatar/UserAvatar.tsx b/site/src/components/UserAvatar/UserAvatar.tsx index d5c283d01072d..95183a8001546 100644 --- a/site/src/components/UserAvatar/UserAvatar.tsx +++ b/site/src/components/UserAvatar/UserAvatar.tsx @@ -1,21 +1,19 @@ -import { Avatar } from "components/Avatar/Avatar" +import { Avatar, AvatarProps } from "components/Avatar/Avatar" import { FC } from "react" -export interface UserAvatarProps { +export type UserAvatarProps = { username: string avatarURL?: string - // It is needed to work with the AvatarGroup so it can pass the - // MuiAvatarGroup-avatar className - className?: string -} +} & AvatarProps export const UserAvatar: FC = ({ username, avatarURL, - className, + + ...avatarProps }) => { return ( - + {username} ) diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 64a0a91cc51a7..6b4d6eda9d090 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -1,11 +1,5 @@ -import CircularProgress from "@mui/material/CircularProgress" -import ErrorIcon from "@mui/icons-material/ErrorOutline" -import StopIcon from "@mui/icons-material/StopOutlined" -import PlayIcon from "@mui/icons-material/PlayArrowOutlined" -import QueuedIcon from "@mui/icons-material/HourglassEmpty" -import { Workspace, WorkspaceBuild } from "api/typesGenerated" +import { Workspace } from "api/typesGenerated" import { Pill } from "components/Pill/Pill" -import i18next from "i18next" import { FC, PropsWithChildren } from "react" import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" @@ -14,82 +8,7 @@ import { ImpendingDeletionBadge, ImpendingDeletionText, } from "components/WorkspaceDeletion" - -const LoadingIcon: FC = () => { - return -} - -export const getStatus = (buildStatus: WorkspaceBuild["status"]) => { - const { t } = i18next - - switch (buildStatus) { - case undefined: - return { - text: t("workspaceStatus.loading", { ns: "common" }), - icon: , - } as const - case "running": - return { - type: "success", - text: t("workspaceStatus.running", { ns: "common" }), - icon: , - } as const - case "starting": - return { - type: "success", - text: t("workspaceStatus.starting", { ns: "common" }), - icon: , - } as const - case "stopping": - return { - type: "warning", - text: t("workspaceStatus.stopping", { ns: "common" }), - icon: , - } as const - case "stopped": - return { - type: "warning", - text: t("workspaceStatus.stopped", { ns: "common" }), - icon: , - } as const - case "deleting": - return { - type: "warning", - text: t("workspaceStatus.deleting", { ns: "common" }), - icon: , - } as const - case "deleted": - return { - type: "error", - text: t("workspaceStatus.deleted", { ns: "common" }), - icon: , - } as const - case "canceling": - return { - type: "warning", - text: t("workspaceStatus.canceling", { ns: "common" }), - icon: , - } as const - case "canceled": - return { - type: "warning", - text: t("workspaceStatus.canceled", { ns: "common" }), - icon: , - } as const - case "failed": - return { - type: "error", - text: t("workspaceStatus.failed", { ns: "common" }), - icon: , - } as const - case "pending": - return { - type: "info", - text: t("workspaceStatus.pending", { ns: "common" }), - icon: , - } as const - } -} +import { getDisplayWorkspaceStatus } from "utils/workspace" export type WorkspaceStatusBadgeProps = { workspace: Workspace @@ -99,7 +18,9 @@ export type WorkspaceStatusBadgeProps = { export const WorkspaceStatusBadge: FC< PropsWithChildren > = ({ workspace, className }) => { - const { text, icon, type } = getStatus(workspace.latest_build.status) + const { text, icon, type } = getDisplayWorkspaceStatus( + workspace.latest_build.status, + ) return ( {/* determines its own visibility */} @@ -117,7 +38,9 @@ export const WorkspaceStatusText: FC< PropsWithChildren > = ({ workspace, className }) => { const styles = useStyles() - const { text, type } = getStatus(workspace.latest_build.status) + const { text, type } = getDisplayWorkspaceStatus( + workspace.latest_build.status, + ) return ( diff --git a/site/src/hooks/index.ts b/site/src/hooks/index.ts index 0187a3fe1d775..613260603fbb5 100644 --- a/site/src/hooks/index.ts +++ b/site/src/hooks/index.ts @@ -2,7 +2,6 @@ export * from "./useClickable" export * from "./useClickableTableRow" export * from "./useClipboard" export * from "./useFeatureVisibility" -export * from "./useFilter" export * from "./useLocalStorage" export * from "./useMe" export * from "./useOrganizationId" diff --git a/site/src/hooks/useFilter.ts b/site/src/hooks/useFilter.ts deleted file mode 100644 index 823c1a22d69d7..0000000000000 --- a/site/src/hooks/useFilter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSearchParams } from "react-router-dom" - -type UseFilterResult = { - query: string - setFilter: (query: string) => void -} - -export const useFilter = (defaultValue: string): UseFilterResult => { - const [searchParams, setSearchParams] = useSearchParams() - const query = searchParams.get("filter") ?? defaultValue - - const setFilter = (query: string) => { - searchParams.set("filter", query) - setSearchParams(searchParams) - } - - return { - query, - setFilter, - } -} diff --git a/site/src/hooks/usePagination.ts b/site/src/hooks/usePagination.ts index 22e2099718613..a610fc4ab01bd 100644 --- a/site/src/hooks/usePagination.ts +++ b/site/src/hooks/usePagination.ts @@ -1,15 +1,13 @@ import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils" import { useSearchParams } from "react-router-dom" -type UsePaginationResult = { - page: number - limit: number - goToPage: (page: number) => void -} - -export const usePagination = (): UsePaginationResult => { - const [searchParams, setSearchParams] = useSearchParams() - const page = searchParams.get("page") ? Number(searchParams.get("page")) : 0 +export const usePagination = ({ + searchParamsResult, +}: { + searchParamsResult: ReturnType +}) => { + const [searchParams, setSearchParams] = searchParamsResult + const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1 const limit = DEFAULT_RECORDS_PER_PAGE const goToPage = (page: number) => { diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index f2ffa61e7e12a..fc7505a5f3ee5 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -2,7 +2,7 @@ import { FC, PropsWithChildren, useState } from "react" import { Section } from "components/SettingsLayout/Section" import { TokensPageView } from "./TokensPageView" import makeStyles from "@mui/styles/makeStyles" -import { useTranslation, Trans } from "react-i18next" +import { useTranslation } from "react-i18next" import { useTokensData } from "./hooks" import { ConfirmDeleteDialog } from "./components" import { Stack } from "components/Stack/Stack" @@ -16,12 +16,6 @@ export const TokensPage: FC> = () => { const { t } = useTranslation("tokensPage") const cliCreateCommand = "coder tokens create" - const description = ( - - Tokens are used to authenticate with the Coder API. You can create a token - with the Coder CLI using the {{ cliCreateCommand }} command. - - ) const TokenActions = () => ( @@ -52,7 +46,13 @@ export const TokensPage: FC> = () => {
+ Tokens are used to authenticate with the Coder API. You can create a + token with the Coder CLI using the {cliCreateCommand}{" "} + command. + + } layout="fluid" > diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index cdd0753f26a32..07a02cc2bc8cb 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,21 +1,54 @@ -import { useFilter } from "hooks/useFilter" import { usePagination } from "hooks/usePagination" import { FC } from "react" import { Helmet } from "react-helmet-async" -import { workspaceFilterQuery } from "utils/filters" import { pageTitle } from "utils/page" import { useWorkspacesData, useWorkspaceUpdate } from "./data" import { WorkspacesPageView } from "./WorkspacesPageView" +import { useFilter } from "./filter/filter" +import { useOrganizationId, usePermissions } from "hooks" +import { + useUsersAutocomplete, + useTemplatesAutocomplete, + useStatusAutocomplete, +} from "./filter/autocompletes" +import { useSearchParams } from "react-router-dom" +import { useDashboard } from "components/Dashboard/DashboardProvider" const WorkspacesPage: FC = () => { - const filter = useFilter(workspaceFilterQuery.me) - const pagination = usePagination() - + const orgId = useOrganizationId() + // If we use a useSearchParams for each hook, the values will not be in sync. + // So we have to use a single one, centralizing the values, and pass it to + // each hook. + const searchParamsResult = useSearchParams() + const pagination = usePagination({ searchParamsResult }) + const filter = useFilter({ + searchParamsResult, + onUpdate: () => { + pagination.goToPage(1) + }, + }) const { data, error, queryKey } = useWorkspacesData({ ...pagination, - ...filter, + query: filter.query, }) const updateWorkspace = useWorkspaceUpdate(queryKey) + const permissions = usePermissions() + const canFilterByUser = permissions.viewDeploymentValues + const usersAutocomplete = useUsersAutocomplete( + filter.values.owner, + (option) => filter.update({ ...filter.values, owner: option?.value }), + canFilterByUser, + ) + const templatesAutocomplete = useTemplatesAutocomplete( + orgId, + filter.values.template, + (option) => filter.update({ ...filter.values, template: option?.value }), + ) + const statusAutocomplete = useStatusAutocomplete( + filter.values.status, + (option) => filter.update({ ...filter.values, status: option?.value }), + ) + const dashboard = useDashboard() return ( <> @@ -24,13 +57,20 @@ const WorkspacesPage: FC = () => { { updateWorkspace.mutate(workspace) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 8308f5343031c..790404a040297 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -1,3 +1,5 @@ +/* eslint-disable eslint-comments/disable-enable-pair -- ignore */ +/* eslint-disable @typescript-eslint/no-explicit-any -- We don't care about any here */ import { ComponentMeta, Story } from "@storybook/react" import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils" import dayjs from "dayjs" @@ -13,13 +15,15 @@ import { MockBuildInfo, MockEntitlementsWithScheduling, MockExperiments, + MockUser, } from "testHelpers/entities" -import { workspaceFilterQuery } from "utils/filters" import { WorkspacesPageView, WorkspacesPageViewProps, } from "./WorkspacesPageView" import { DashboardProviderContext } from "components/Dashboard/DashboardProvider" +import { action } from "@storybook/addon-actions" +import { ComponentProps } from "react" const createWorkspace = ( status: WorkspaceStatus, @@ -69,11 +73,41 @@ const MockedAppearance = { save: () => null, } +const mockAutocomplete = { + initialOption: undefined, + isInitializing: false, + isSearching: false, + query: "", + searchOptions: [], + selectedOption: undefined, + selectOption: action("selectOption"), + setQuery: action("updateQuery"), +} + +const defaultFilterProps = { + filter: { + query: `owner:${MockUser.username}`, + update: () => action("update"), + debounceUpdate: action("debounce") as any, + values: { + owner: MockUser.username, + template: undefined, + status: undefined, + }, + }, + autocomplete: { + users: mockAutocomplete, + templates: mockAutocomplete, + status: mockAutocomplete, + }, +} as ComponentProps["filterProps"] + export default { title: "pages/WorkspacesPageView", component: WorkspacesPageView, args: { limit: DEFAULT_RECORDS_PER_PAGE, + filterProps: defaultFilterProps, }, } as ComponentMeta @@ -99,13 +133,18 @@ AllStates.args = { export const OwnerHasNoWorkspaces = Template.bind({}) OwnerHasNoWorkspaces.args = { workspaces: [], - filter: workspaceFilterQuery.me, count: 0, } export const NoSearchResults = Template.bind({}) NoSearchResults.args = { workspaces: [], - filter: "searchtearmwithnoresults", + filterProps: { + ...defaultFilterProps, + filter: { + ...defaultFilterProps.filter, + query: "searchwithnoresults", + }, + }, count: 0, } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index c6778e85ae1d5..bdb97241e3383 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -2,7 +2,7 @@ import Link from "@mui/material/Link" import { Workspace } from "api/typesGenerated" import { Maybe } from "components/Conditionals/Maybe" import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase" -import { FC } from "react" +import { ComponentProps, FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Margins } from "components/Margins/Margins" import { @@ -10,15 +10,17 @@ import { PageHeaderSubtitle, PageHeaderTitle, } from "components/PageHeader/PageHeader" -import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter" import { Stack } from "components/Stack/Stack" import { WorkspaceHelpTooltip } from "components/Tooltips" import { WorkspacesTable } from "components/WorkspacesTable/WorkspacesTable" -import { workspaceFilterQuery } from "utils/filters" import { useLocalStorage } from "hooks" import difference from "lodash/difference" import { ImpendingDeletionBanner, Count } from "components/WorkspaceDeletion" import { ErrorAlert } from "components/Alert/ErrorAlert" +import { Filter } from "./filter/filter" +import { hasError, isApiValidationError } from "api/errors" +import { workspaceFilterQuery } from "utils/filters" +import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter" export const Language = { pageTitle: "Workspaces", @@ -46,11 +48,11 @@ export interface WorkspacesPageViewProps { error: unknown workspaces?: Workspace[] count?: number + useNewFilter?: boolean page: number limit: number - filter: string + filterProps: ComponentProps onPageChange: (page: number) => void - onFilter: (query: string) => void onUpdateWorkspace: (workspace: Workspace) => void } @@ -59,13 +61,13 @@ export const WorkspacesPageView: FC< > = ({ workspaces, error, - filter, - page, limit, count, - onFilter, + filterProps, onPageChange, onUpdateWorkspace, + useNewFilter, + page, }) => { const { saveLocal, getLocal } = useLocalStorage() @@ -114,7 +116,7 @@ export const WorkspacesPageView: FC< - + {/* determines its own visibility */} @@ -130,16 +132,20 @@ export const WorkspacesPageView: FC< count={Count.Multiple} /> - + {useNewFilter ? ( + + ) : ( + + )} diff --git a/site/src/pages/WorkspacesPage/data.ts b/site/src/pages/WorkspacesPage/data.ts index 799d94dc6158e..e3d71ab80853f 100644 --- a/site/src/pages/WorkspacesPage/data.ts +++ b/site/src/pages/WorkspacesPage/data.ts @@ -12,6 +12,7 @@ import { WorkspacesResponse, } from "api/typesGenerated" import { displayError } from "components/GlobalSnackbar/utils" +import { useState } from "react" import { useTranslation } from "react-i18next" type UseWorkspacesDataParams = { @@ -26,17 +27,22 @@ export const useWorkspacesData = ({ query, }: UseWorkspacesDataParams) => { const queryKey = ["workspaces", query, page] + const [shouldRefetch, setShouldRefetch] = useState(true) const result = useQuery({ queryKey, queryFn: () => getWorkspaces({ q: query, limit: limit, - // If the page is <= 0, just use offset 0. This usually happens - // if the page is not provided. offset: page <= 0 ? 0 : (page - 1) * limit, }), - refetchInterval: 5_000, + onSuccess: () => { + setShouldRefetch(true) + }, + onError: () => { + setShouldRefetch(false) + }, + refetchInterval: shouldRefetch ? 5_000 : undefined, }) return { diff --git a/site/src/pages/WorkspacesPage/filter/autocompletes.ts b/site/src/pages/WorkspacesPage/filter/autocompletes.ts new file mode 100644 index 0000000000000..75ed71144286e --- /dev/null +++ b/site/src/pages/WorkspacesPage/filter/autocompletes.ts @@ -0,0 +1,212 @@ +import { useMemo, useRef, useState } from "react" +import { + BaseOption, + OwnerOption, + StatusOption, + TemplateOption, +} from "./options" +import { useQuery } from "@tanstack/react-query" +import { getTemplates, getUsers } from "api/api" +import { WorkspaceStatuses } from "api/typesGenerated" +import { getDisplayWorkspaceStatus } from "utils/workspace" + +type UseAutocompleteOptions = { + id: string + value: string | undefined + // Using null because of react-query + // https://tanstack.com/query/v4/docs/react/guides/migrating-to-react-query-4#undefined-is-an-illegal-cache-value-for-successful-queries + getSelectedOption: () => Promise + getOptions: (query: string) => Promise + onChange: (option: TOption | undefined) => void + enabled?: boolean +} + +const useAutocomplete = ({ + id, + value, + getSelectedOption, + getOptions, + onChange, + enabled, +}: UseAutocompleteOptions) => { + const selectedOptionsCacheRef = useRef>({}) + const [query, setQuery] = useState("") + const selectedOptionQuery = useQuery({ + queryKey: [id, "autocomplete", "selected", value], + queryFn: () => { + if (!value) { + return null + } + + const cachedOption = selectedOptionsCacheRef.current[value] + if (cachedOption) { + return cachedOption + } + + return getSelectedOption() + }, + enabled, + keepPreviousData: true, + }) + const selectedOption = selectedOptionQuery.data + const searchOptionsQuery = useQuery({ + queryKey: [id, "autocomplete", "search"], + queryFn: () => getOptions(query), + enabled, + }) + const searchOptions = useMemo(() => { + const isDataLoaded = + searchOptionsQuery.isFetched && selectedOptionQuery.isFetched + + if (!isDataLoaded) { + return undefined + } + + let options = searchOptionsQuery.data as TOption[] + + if (selectedOption) { + options = options.filter( + (option) => option.value !== selectedOption.value, + ) + options = [selectedOption, ...options] + } + + options = options.filter( + (option) => + option.label.toLowerCase().includes(query.toLowerCase()) || + option.value.toLowerCase().includes(query.toLowerCase()), + ) + + return options + }, [ + selectedOptionQuery.isFetched, + query, + searchOptionsQuery.data, + searchOptionsQuery.isFetched, + selectedOption, + ]) + + const selectOption = (option: TOption) => { + let newSelectedOptionValue: TOption | undefined = option + selectedOptionsCacheRef.current[option.value] = option + setQuery("") + + if (option.value === selectedOption?.value) { + newSelectedOptionValue = undefined + } + + onChange(newSelectedOptionValue) + } + + return { + query, + setQuery, + selectedOption, + selectOption, + searchOptions, + isInitializing: selectedOptionQuery.isInitialLoading, + initialOption: selectedOptionQuery.data, + isSearching: searchOptionsQuery.isFetching, + } +} + +export const useUsersAutocomplete = ( + value: string | undefined, + onChange: (option: OwnerOption | undefined) => void, + enabled?: boolean, +) => + useAutocomplete({ + onChange, + enabled, + value, + id: "owner", + getSelectedOption: async () => { + const usersRes = await getUsers({ q: value, limit: 1 }) + const firstUser = usersRes.users.at(0) + if (firstUser && firstUser.username === value) { + return { + label: firstUser.username, + value: firstUser.username, + avatarUrl: firstUser.avatar_url, + } + } + return null + }, + getOptions: async (query) => { + const usersRes = await getUsers({ q: query, limit: 25 }) + return usersRes.users.map((user) => ({ + label: user.username, + value: user.username, + avatarUrl: user.avatar_url, + })) + }, + }) + +export type UsersAutocomplete = ReturnType + +export const useTemplatesAutocomplete = ( + orgId: string, + value: string | undefined, + onChange: (option: TemplateOption | undefined) => void, +) => { + return useAutocomplete({ + onChange, + value, + id: "template", + getSelectedOption: async () => { + const templates = await getTemplates(orgId) + const template = templates.find((template) => template.name === value) + if (template) { + return { + label: + template.display_name !== "" + ? template.display_name + : template.name, + value: template.name, + icon: template.icon, + } + } + return null + }, + getOptions: async (query) => { + const templates = await getTemplates(orgId) + const filteredTemplates = templates.filter( + (template) => + template.name.toLowerCase().includes(query.toLowerCase()) || + template.display_name.toLowerCase().includes(query.toLowerCase()), + ) + return filteredTemplates.map((template) => ({ + label: + template.display_name !== "" ? template.display_name : template.name, + value: template.name, + icon: template.icon, + })) + }, + }) +} + +export type TemplatesAutocomplete = ReturnType + +export const useStatusAutocomplete = ( + value: string | undefined, + onChange: (option: StatusOption | undefined) => void, +) => { + const statusOptions = WorkspaceStatuses.map((status) => { + const display = getDisplayWorkspaceStatus(status) + return { + label: display.text, + value: status, + color: display.type ?? "warning", + } as StatusOption + }) + return useAutocomplete({ + onChange, + value, + id: "status", + getSelectedOption: async () => + statusOptions.find((option) => option.value === value) ?? null, + getOptions: async () => statusOptions, + }) +} + +export type StatusAutocomplete = ReturnType diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx new file mode 100644 index 0000000000000..4822e71cb6844 --- /dev/null +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -0,0 +1,630 @@ +import { FC, ReactNode, forwardRef, useEffect, useRef, useState } from "react" +import Box from "@mui/material/Box" +import TextField from "@mui/material/TextField" +import { UserAvatar } from "components/UserAvatar/UserAvatar" +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown" +import Button, { ButtonProps } from "@mui/material/Button" +import Menu, { MenuProps } from "@mui/material/Menu" +import MenuItem from "@mui/material/MenuItem" +import SearchOutlined from "@mui/icons-material/SearchOutlined" +import { Avatar, AvatarProps } from "components/Avatar/Avatar" +import InputAdornment from "@mui/material/InputAdornment" +import { Palette, PaletteColor } from "@mui/material/styles" +import IconButton from "@mui/material/IconButton" +import Tooltip from "@mui/material/Tooltip" +import CloseOutlined from "@mui/icons-material/CloseOutlined" +import { Loader } from "components/Loader/Loader" +import MenuList from "@mui/material/MenuList" +import { useSearchParams } from "react-router-dom" +import Skeleton, { SkeletonProps } from "@mui/material/Skeleton" +import CheckOutlined from "@mui/icons-material/CheckOutlined" +import { + getValidationErrorMessage, + hasError, + isApiValidationError, +} from "api/errors" +import { + UsersAutocomplete, + TemplatesAutocomplete, + StatusAutocomplete, +} from "./autocompletes" +import { + OwnerOption, + TemplateOption, + StatusOption, + BaseOption, +} from "./options" +import debounce from "just-debounce-it" + +export type FilterValues = { + owner?: string // User["username"] + status?: string // WorkspaceStatus + template?: string // Template["name"] +} + +export const useFilter = ({ + onUpdate, + searchParamsResult, +}: { + searchParamsResult: ReturnType + onUpdate?: () => void +}) => { + const [searchParams, setSearchParams] = searchParamsResult + const query = searchParams.get("filter") ?? "" + const values = parseFilterQuery(query) + + const update = (values: string | FilterValues) => { + if (typeof values === "string") { + searchParams.set("filter", values) + } else { + searchParams.set("filter", stringifyFilter(values)) + } + setSearchParams(searchParams) + if (onUpdate) { + onUpdate() + } + } + + const debounceUpdate = debounce( + (values: string | FilterValues) => update(values), + 500, + ) + + return { + query, + update, + debounceUpdate, + values, + } +} + +const parseFilterQuery = (filterQuery: string): FilterValues => { + if (filterQuery === "") { + return {} + } + + const pairs = filterQuery.split(" ") + const result: FilterValues = {} + + for (const pair of pairs) { + const [key, value] = pair.split(":") as [ + keyof FilterValues, + string | undefined, + ] + if (value) { + result[key] = value + } + } + + return result +} + +const stringifyFilter = (filterValue: FilterValues): string => { + let result = "" + + for (const key in filterValue) { + const value = filterValue[key as keyof FilterValues] + if (value) { + result += `${key}:${value} ` + } + } + + return result.trim() +} + +const FilterSkeleton = (props: SkeletonProps) => { + return ( + theme.palette.background.paperLight, + borderRadius: "6px", + ...props.sx, + }} + /> + ) +} + +export const Filter = ({ + filter, + autocomplete, + error, +}: { + filter: ReturnType + error?: unknown + autocomplete: { + users?: UsersAutocomplete + templates: TemplatesAutocomplete + status: StatusAutocomplete + } +}) => { + const shouldDisplayError = hasError(error) && isApiValidationError(error) + const hasFilterQuery = filter.query !== "" + const isIinitializingFilters = + autocomplete.status.isInitializing || + autocomplete.templates.isInitializing || + (autocomplete.users && autocomplete.users.isInitializing) + const [searchQuery, setSearchQuery] = useState(filter.query) + + useEffect(() => { + setSearchQuery(filter.query) + }, [filter.query]) + + if (isIinitializingFilters) { + return ( + + + {autocomplete.users && ( + + )} + + + + ) + } + + return ( + + { + setSearchQuery(e.target.value) + filter.debounceUpdate(e.target.value) + }, + sx: { + borderRadius: "6px", + "& input::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }, + startAdornment: ( + + theme.palette.text.secondary, + }} + /> + + ), + endAdornment: hasFilterQuery && ( + + + { + filter.update("") + }} + > + + + + + ), + }} + /> + + {autocomplete.users && } + + + + ) +} + +const OwnerFilter = ({ autocomplete }: { autocomplete: UsersAutocomplete }) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ width: 200 }} + > + {autocomplete.selectedOption ? ( + + ) : ( + "All users" + )} + + ( + { + autocomplete.selectOption(option) + handleClose() + }} + > + + + )} + /> +
+ ) +} + +const UserOptionItem = ({ + option, + isSelected, +}: { + option: OwnerOption + isSelected?: boolean +}) => { + return ( + + } + /> + ) +} + +const TemplatesFilter = ({ + autocomplete, +}: { + autocomplete: TemplatesAutocomplete +}) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ width: 200 }} + > + {autocomplete.selectedOption ? ( + + ) : ( + "All templates" + )} + + ( + { + autocomplete.selectOption(option) + handleClose() + }} + > + + + )} + /> +
+ ) +} + +const TemplateOptionItem = ({ + option, + isSelected, +}: { + option: TemplateOption + isSelected?: boolean +}) => { + return ( + + } + /> + ) +} + +const TemplateAvatar: FC< + AvatarProps & { templateName: string; icon?: string } +> = ({ templateName, icon, ...avatarProps }) => { + return icon ? ( + + ) : ( + {templateName} + ) +} + +const StatusFilter = ({ + autocomplete, +}: { + autocomplete: StatusAutocomplete +}) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ width: 200 }} + > + {autocomplete.selectedOption ? ( + + ) : ( + "All statuses" + )} + + + {autocomplete.searchOptions?.map((option) => ( + { + autocomplete.selectOption(option) + handleClose() + }} + > + + + ))} + +
+ ) +} + +const StatusOptionItem = ({ + option, + isSelected, +}: { + option: StatusOption + isSelected?: boolean +}) => { + return ( + } + isSelected={isSelected} + /> + ) +} + +const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => { + return ( + + (theme.palette[option.color as keyof Palette] as PaletteColor).light, + }} + /> + ) +} + +type OptionItemProps = { + option: BaseOption + left?: ReactNode + isSelected?: boolean +} + +const OptionItem = ({ option, left, isSelected }: OptionItemProps) => { + return ( + + {left} + + {option.label} + + {isSelected && ( + + )} + + ) +} + +const MenuButton = forwardRef((props, ref) => { + return ( +