refactor(site): Improve workspaces filtering (#7681)

This commit is contained in:
Bruno Quaresma
2023-05-30 14:52:13 -03:00
committed by GitHub
parent c795a0e500
commit 77b0ca0b53
29 changed files with 1148 additions and 183 deletions
+4 -2
View File
@@ -7554,11 +7554,13 @@ const docTemplate = `{
"type": "string",
"enum": [
"moons",
"workspace_actions"
"workspace_actions",
"workspace_filter"
],
"x-enum-varnames": [
"ExperimentMoons",
"ExperimentWorkspaceActions"
"ExperimentWorkspaceActions",
"ExperimentWorkspaceFilter"
]
},
"codersdk.Feature": {
+6 -2
View File
@@ -6747,8 +6747,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",
+3
View File
@@ -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"
)
+1
View File
@@ -2561,6 +2561,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
| ------------------- |
| `moons` |
| `workspace_actions` |
| `workspace_filter` |
## codersdk.Feature
+1
View File
@@ -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
+2 -2
View File
@@ -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",
+10
View File
@@ -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<T>(s: string): T
}
View File
+3
View File
@@ -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 => {
+6 -2
View File
@@ -1386,8 +1386,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 =
@@ -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") {
@@ -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<UserAvatarProps> = ({
username,
avatarURL,
className,
...avatarProps
}) => {
return (
<Avatar title={username} src={avatarURL} className={className}>
<Avatar title={username} src={avatarURL} {...avatarProps}>
{username}
</Avatar>
)
@@ -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 <CircularProgress size={10} style={{ color: "#FFF" }} />
}
export const getStatus = (buildStatus: WorkspaceBuild["status"]) => {
const { t } = i18next
switch (buildStatus) {
case undefined:
return {
text: t("workspaceStatus.loading", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "running":
return {
type: "success",
text: t("workspaceStatus.running", { ns: "common" }),
icon: <PlayIcon />,
} as const
case "starting":
return {
type: "success",
text: t("workspaceStatus.starting", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "stopping":
return {
type: "warning",
text: t("workspaceStatus.stopping", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "stopped":
return {
type: "warning",
text: t("workspaceStatus.stopped", { ns: "common" }),
icon: <StopIcon />,
} as const
case "deleting":
return {
type: "warning",
text: t("workspaceStatus.deleting", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "deleted":
return {
type: "error",
text: t("workspaceStatus.deleted", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "canceling":
return {
type: "warning",
text: t("workspaceStatus.canceling", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "canceled":
return {
type: "warning",
text: t("workspaceStatus.canceled", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "failed":
return {
type: "error",
text: t("workspaceStatus.failed", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "pending":
return {
type: "info",
text: t("workspaceStatus.pending", { ns: "common" }),
icon: <QueuedIcon />,
} as const
}
}
import { getDisplayWorkspaceStatus } from "utils/workspace"
export type WorkspaceStatusBadgeProps = {
workspace: Workspace
@@ -99,7 +18,9 @@ export type WorkspaceStatusBadgeProps = {
export const WorkspaceStatusBadge: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ workspace, className }) => {
const { text, icon, type } = getStatus(workspace.latest_build.status)
const { text, icon, type } = getDisplayWorkspaceStatus(
workspace.latest_build.status,
)
return (
<ChooseOne>
{/* <ImpendingDeletionBadge/> determines its own visibility */}
@@ -117,7 +38,9 @@ export const WorkspaceStatusText: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ workspace, className }) => {
const styles = useStyles()
const { text, type } = getStatus(workspace.latest_build.status)
const { text, type } = getDisplayWorkspaceStatus(
workspace.latest_build.status,
)
return (
<ChooseOne>
-1
View File
@@ -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"
-21
View File
@@ -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,
}
}
+7 -9
View File
@@ -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<typeof useSearchParams>
}) => {
const [searchParams, setSearchParams] = searchParamsResult
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1
const limit = DEFAULT_RECORDS_PER_PAGE
const goToPage = (page: number) => {
@@ -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<PropsWithChildren<unknown>> = () => {
const { t } = useTranslation("tokensPage")
const cliCreateCommand = "coder tokens create"
const description = (
<Trans t={t} i18nKey="description" values={{ cliCreateCommand }}>
Tokens are used to authenticate with the Coder API. You can create a token
with the Coder CLI using the <code>{{ cliCreateCommand }}</code> command.
</Trans>
)
const TokenActions = () => (
<Stack direction="row" justifyContent="end" className={styles.tokenActions}>
@@ -52,7 +46,13 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
<Section
title={t("title")}
className={styles.section}
description={description}
description={
<>
Tokens are used to authenticate with the Coder API. You can create a
token with the Coder CLI using the <code>{cliCreateCommand}</code>{" "}
command.
</>
}
layout="fluid"
>
<TokenActions />
@@ -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 = () => {
</Helmet>
<WorkspacesPageView
useNewFilter={dashboard.experiments.includes("workspace_filter")}
workspaces={data?.workspaces}
error={error}
filter={filter.query}
onFilter={filter.setFilter}
count={data?.count}
page={pagination.page}
limit={pagination.limit}
filterProps={{
filter,
autocomplete: {
users: canFilterByUser ? usersAutocomplete : undefined,
templates: templatesAutocomplete,
status: statusAutocomplete,
},
}}
onPageChange={pagination.goToPage}
onUpdateWorkspace={(workspace) => {
updateWorkspace.mutate(workspace)
@@ -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<typeof WorkspacesPageView>["filterProps"]
export default {
title: "pages/WorkspacesPageView",
component: WorkspacesPageView,
args: {
limit: DEFAULT_RECORDS_PER_PAGE,
filterProps: defaultFilterProps,
},
} as ComponentMeta<typeof WorkspacesPageView>
@@ -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,
}
@@ -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<typeof Filter>
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<
</PageHeader>
<Stack>
<Maybe condition={Boolean(error)}>
<Maybe condition={hasError(error) && !isApiValidationError(error)}>
<ErrorAlert error={error} />
</Maybe>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
@@ -130,16 +132,20 @@ export const WorkspacesPageView: FC<
count={Count.Multiple}
/>
<SearchBarWithFilter
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
error={error}
/>
{useNewFilter ? (
<Filter error={error} {...filterProps} />
) : (
<SearchBarWithFilter
filter={filterProps.filter.query}
onFilter={filterProps.filter.debounceUpdate}
presetFilters={presetFilters}
error={error}
/>
)}
</Stack>
<WorkspacesTable
workspaces={workspaces}
isUsingFilter={filter !== workspaceFilterQuery.me}
isUsingFilter={filterProps.filter.query !== ""}
onUpdateWorkspace={onUpdateWorkspace}
error={error}
/>
+9 -3
View File
@@ -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 {
@@ -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<TOption extends BaseOption> = {
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<TOption | null>
getOptions: (query: string) => Promise<TOption[]>
onChange: (option: TOption | undefined) => void
enabled?: boolean
}
const useAutocomplete = <TOption extends BaseOption = BaseOption>({
id,
value,
getSelectedOption,
getOptions,
onChange,
enabled,
}: UseAutocompleteOptions<TOption>) => {
const selectedOptionsCacheRef = useRef<Record<string, TOption>>({})
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<typeof useUsersAutocomplete>
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<typeof useTemplatesAutocomplete>
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<typeof useStatusAutocomplete>
@@ -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<typeof useSearchParams>
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 (
<Skeleton
variant="rectangular"
height={36}
{...props}
sx={{
bgcolor: (theme) => theme.palette.background.paperLight,
borderRadius: "6px",
...props.sx,
}}
/>
)
}
export const Filter = ({
filter,
autocomplete,
error,
}: {
filter: ReturnType<typeof useFilter>
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 (
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
<FilterSkeleton width="100%" />
{autocomplete.users && (
<FilterSkeleton width="200px" sx={{ flexShrink: 0 }} />
)}
<FilterSkeleton width="200px" sx={{ flexShrink: 0 }} />
<FilterSkeleton width="200px" sx={{ flexShrink: 0 }} />
</Box>
)
}
return (
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
<TextField
fullWidth
error={shouldDisplayError}
helperText={
shouldDisplayError ? getValidationErrorMessage(error) : undefined
}
size="small"
InputProps={{
name: "query",
placeholder: "Search...",
value: searchQuery,
onChange: (e) => {
setSearchQuery(e.target.value)
filter.debounceUpdate(e.target.value)
},
sx: {
borderRadius: "6px",
"& input::placeholder": {
color: (theme) => theme.palette.text.secondary,
},
},
startAdornment: (
<InputAdornment position="start">
<SearchOutlined
sx={{
fontSize: 14,
color: (theme) => theme.palette.text.secondary,
}}
/>
</InputAdornment>
),
endAdornment: hasFilterQuery && (
<InputAdornment position="end">
<Tooltip title="Clear filter">
<IconButton
size="small"
onClick={() => {
filter.update("")
}}
>
<CloseOutlined sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
{autocomplete.users && <OwnerFilter autocomplete={autocomplete.users} />}
<TemplatesFilter autocomplete={autocomplete.templates} />
<StatusFilter autocomplete={autocomplete.status} />
</Box>
)
}
const OwnerFilter = ({ autocomplete }: { autocomplete: UsersAutocomplete }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<UserOptionItem option={autocomplete.selectedOption} />
) : (
"All users"
)}
</MenuButton>
<SearchMenu
id="user-filter-menu"
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
options={autocomplete.searchOptions}
query={autocomplete.query}
onQueryChange={autocomplete.setQuery}
renderOption={(option) => (
<MenuItem
key={option.label}
selected={option.value === autocomplete.selectedOption?.value}
onClick={() => {
autocomplete.selectOption(option)
handleClose()
}}
>
<UserOptionItem
option={option}
isSelected={option.value === autocomplete.selectedOption?.value}
/>
</MenuItem>
)}
/>
</div>
)
}
const UserOptionItem = ({
option,
isSelected,
}: {
option: OwnerOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<UserAvatar
username={option.label}
avatarURL={option.avatarUrl}
sx={{ width: 16, height: 16, fontSize: 8 }}
/>
}
/>
)
}
const TemplatesFilter = ({
autocomplete,
}: {
autocomplete: TemplatesAutocomplete
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<TemplateOptionItem option={autocomplete.selectedOption} />
) : (
"All templates"
)}
</MenuButton>
<SearchMenu
id="template-filter-menu"
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
options={autocomplete.searchOptions}
query={autocomplete.query}
onQueryChange={autocomplete.setQuery}
renderOption={(option) => (
<MenuItem
key={option.label}
selected={option.value === autocomplete.selectedOption?.value}
onClick={() => {
autocomplete.selectOption(option)
handleClose()
}}
>
<TemplateOptionItem
option={option}
isSelected={option.value === autocomplete.selectedOption?.value}
/>
</MenuItem>
)}
/>
</div>
)
}
const TemplateOptionItem = ({
option,
isSelected,
}: {
option: TemplateOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<TemplateAvatar
templateName={option.label}
icon={option.icon}
sx={{ width: 14, height: 14, fontSize: 8 }}
/>
}
/>
)
}
const TemplateAvatar: FC<
AvatarProps & { templateName: string; icon?: string }
> = ({ templateName, icon, ...avatarProps }) => {
return icon ? (
<Avatar src={icon} variant="square" fitImage {...avatarProps} />
) : (
<Avatar {...avatarProps}>{templateName}</Avatar>
)
}
const StatusFilter = ({
autocomplete,
}: {
autocomplete: StatusAutocomplete
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<StatusOptionItem option={autocomplete.selectedOption} />
) : (
"All statuses"
)}
</MenuButton>
<Menu
id="status-filter-menu"
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
sx={{ "& .MuiPaper-root": { minWidth: 200 } }}
// Disabled this so when we clear the filter and do some sorting in the
// search items it does not look strange. Github removes exit transitions
// on their filters as well.
transitionDuration={{
enter: 250,
exit: 0,
}}
>
{autocomplete.searchOptions?.map((option) => (
<MenuItem
key={option.label}
selected={option.value === autocomplete.selectedOption?.value}
onClick={() => {
autocomplete.selectOption(option)
handleClose()
}}
>
<StatusOptionItem
option={option}
isSelected={option.value === autocomplete.selectedOption?.value}
/>
</MenuItem>
))}
</Menu>
</div>
)
}
const StatusOptionItem = ({
option,
isSelected,
}: {
option: StatusOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
left={<StatusIndicator option={option} />}
isSelected={isSelected}
/>
)
}
const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => {
return (
<Box
height={8}
width={8}
borderRadius={9999}
sx={{
backgroundColor: (theme) =>
(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 (
<Box
display="flex"
alignItems="center"
gap={2}
fontSize={14}
overflow="hidden"
width="100%"
>
{left}
<Box component="span" overflow="hidden" textOverflow="ellipsis">
{option.label}
</Box>
{isSelected && (
<CheckOutlined sx={{ width: 16, height: 16, marginLeft: "auto" }} />
)}
</Box>
)
}
const MenuButton = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return (
<Button
ref={ref}
endIcon={<KeyboardArrowDown />}
{...props}
sx={{
borderRadius: "6px",
justifyContent: "space-between",
lineHeight: "120%",
...props.sx,
}}
/>
)
})
function SearchMenu<TOption extends { label: string; value: string }>({
options,
renderOption,
query,
onQueryChange,
...menuProps
}: Pick<MenuProps, "anchorEl" | "open" | "onClose" | "id"> & {
options?: TOption[]
renderOption: (option: TOption) => ReactNode
query: string
onQueryChange: (query: string) => void
}) {
const menuListRef = useRef<HTMLUListElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
return (
<Menu
{...menuProps}
onClose={(event, reason) => {
menuProps.onClose && menuProps.onClose(event, reason)
onQueryChange("")
}}
sx={{
"& .MuiPaper-root": {
width: 320,
paddingY: 0,
},
}}
// 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,
}}
>
<Box
component="li"
sx={{
display: "flex",
alignItems: "center",
paddingLeft: 2,
height: 40,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "ArrowDown" && menuListRef.current) {
const firstItem = menuListRef.current.firstChild as HTMLElement
firstItem.focus()
}
}}
>
<SearchOutlined
sx={{
fontSize: 14,
color: (theme) => theme.palette.text.secondary,
}}
/>
<Box
tabIndex={-1}
component="input"
type="text"
placeholder="Search..."
autoFocus
value={query}
ref={searchInputRef}
onChange={(e) => {
onQueryChange(e.target.value)
}}
sx={{
height: "100%",
border: 0,
background: "none",
width: "100%",
marginLeft: 2,
outline: 0,
"&::placeholder": {
color: (theme) => theme.palette.text.secondary,
},
}}
/>
</Box>
<Box component="li" sx={{ maxHeight: 480, overflowY: "auto" }}>
<MenuList
ref={menuListRef}
onKeyDown={(e) => {
if (e.shiftKey && e.code === "Tab") {
e.preventDefault()
e.stopPropagation()
searchInputRef.current?.focus()
}
}}
>
{options ? (
options.length > 0 ? (
options.map(renderOption)
) : (
<Box
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
textAlign: "center",
py: 1,
}}
>
No results
</Box>
)
) : (
<Loader size={14} />
)}
</MenuList>
</Box>
</Menu>
)
}
@@ -0,0 +1,16 @@
export type BaseOption = {
label: string
value: string
}
export type OwnerOption = BaseOption & {
avatarUrl?: string
}
export type StatusOption = BaseOption & {
color: string
}
export type TemplateOption = BaseOption & {
icon?: string
}
+1
View File
@@ -1402,6 +1402,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
export const MockExperiments: TypesGen.Experiment[] = [
"workspace_actions",
"moons",
"workspace_filter",
]
export const MockAuditLog: TypesGen.AuditLog = {
+9 -3
View File
@@ -344,10 +344,15 @@ dark = createTheme(dark, {
height: "auto",
},
colorPrimary: {
// The default outlined input color is white, which seemed jarring.
"&:hover:not(.Mui-error) .MuiOutlinedInput-notchedOutline": {
borderColor: colors.gray[7],
// Same as button
"& .MuiOutlinedInput-notchedOutline": {
borderColor: colors.gray[12],
},
// The default outlined input color is white, which seemed jarring.
"&:hover:not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline":
{
borderColor: colors.gray[7],
},
},
},
},
@@ -395,6 +400,7 @@ dark = createTheme(dark, {
tooltip: {
lineHeight: "150%",
borderRadius: 4,
background: dark.palette.divider,
},
},
},
@@ -5,6 +5,12 @@ import minMax from "dayjs/plugin/minMax"
import utc from "dayjs/plugin/utc"
import semver from "semver"
import * as TypesGen from "../api/typesGenerated"
import i18next from "i18next"
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"
dayjs.extend(duration)
dayjs.extend(utc)
@@ -185,3 +191,81 @@ export const getDisplayWorkspaceTemplateName = (
? workspace.template_display_name
: workspace.template_name
}
export const getDisplayWorkspaceStatus = (
workspaceStatus: TypesGen.WorkspaceStatus,
) => {
const { t } = i18next
switch (workspaceStatus) {
case undefined:
return {
text: t("workspaceStatus.loading", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "running":
return {
type: "success",
text: t("workspaceStatus.running", { ns: "common" }),
icon: <PlayIcon />,
} as const
case "starting":
return {
type: "success",
text: t("workspaceStatus.starting", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "stopping":
return {
type: "warning",
text: t("workspaceStatus.stopping", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "stopped":
return {
type: "warning",
text: t("workspaceStatus.stopped", { ns: "common" }),
icon: <StopIcon />,
} as const
case "deleting":
return {
type: "warning",
text: t("workspaceStatus.deleting", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "deleted":
return {
type: "error",
text: t("workspaceStatus.deleted", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "canceling":
return {
type: "warning",
text: t("workspaceStatus.canceling", { ns: "common" }),
icon: <LoadingIcon />,
} as const
case "canceled":
return {
type: "warning",
text: t("workspaceStatus.canceled", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "failed":
return {
type: "error",
text: t("workspaceStatus.failed", { ns: "common" }),
icon: <ErrorIcon />,
} as const
case "pending":
return {
type: "info",
text: t("workspaceStatus.pending", { ns: "common" }),
icon: <QueuedIcon />,
} as const
}
}
const LoadingIcon = () => {
return <CircularProgress size={10} style={{ color: "#FFF" }} />
}
+8 -8
View File
@@ -3329,10 +3329,10 @@
"@types/react" "*"
"@types/reactcss" "*"
"@types/react-dom@18.0.6":
version "18.0.6"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1"
integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==
"@types/react-dom@18.2.4":
version "18.2.4"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.4.tgz#13f25bfbf4e404d26f62ac6e406591451acba9e0"
integrity sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==
dependencies:
"@types/react" "*"
@@ -3394,10 +3394,10 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/react@18.0.15":
version "18.0.15"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe"
integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow==
"@types/react@18.2.6":
version "18.2.6"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.6.tgz#5cd53ee0d30ffc193b159d3516c8c8ad2f19d571"
integrity sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"