mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
refactor(site): Improve workspaces filtering (#7681)
This commit is contained in:
Generated
+4
-2
@@ -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": {
|
||||
|
||||
Generated
+6
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -2561,6 +2561,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a
|
||||
| ------------------- |
|
||||
| `moons` |
|
||||
| `workspace_actions` |
|
||||
| `workspace_filter` |
|
||||
|
||||
## codersdk.Feature
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+10
@@ -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
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1402,6 +1402,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
|
||||
export const MockExperiments: TypesGen.Experiment[] = [
|
||||
"workspace_actions",
|
||||
"moons",
|
||||
"workspace_filter",
|
||||
]
|
||||
|
||||
export const MockAuditLog: TypesGen.AuditLog = {
|
||||
|
||||
@@ -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
@@ -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" "*"
|
||||
|
||||
Reference in New Issue
Block a user