feat(site): add new filter to the users page (#7818)

This commit is contained in:
Bruno Quaresma
2023-06-06 09:59:15 -03:00
committed by GitHub
parent ee45b3df77
commit baa9922a82
15 changed files with 1105 additions and 855 deletions
+482
View File
@@ -0,0 +1,482 @@
import { ReactNode, forwardRef, useEffect, useRef, useState } from "react"
import Box from "@mui/material/Box"
import TextField from "@mui/material/TextField"
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 InputAdornment from "@mui/material/InputAdornment"
import IconButton from "@mui/material/IconButton"
import Tooltip from "@mui/material/Tooltip"
import CloseOutlined from "@mui/icons-material/CloseOutlined"
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 { useFilterMenu } from "./menu"
import { BaseOption } from "./options"
import debounce from "just-debounce-it"
import MenuList from "@mui/material/MenuList"
import { Loader } from "components/Loader/Loader"
type FilterValues = Record<string, string | undefined>
export const useFilter = ({
initialValue = "",
onUpdate,
searchParamsResult,
}: {
initialValue?: string
searchParamsResult: ReturnType<typeof useSearchParams>
onUpdate?: () => void
}) => {
const [searchParams, setSearchParams] = searchParamsResult
const query = searchParams.get("filter") ?? initialValue
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,
)
const used = query !== "" && query !== initialValue
return {
query,
update,
debounceUpdate,
values,
used,
}
}
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]
if (value) {
result += `${key}:${value} `
}
}
return result.trim()
}
const BaseSkeleton = (props: SkeletonProps) => {
return (
<Skeleton
variant="rectangular"
height={36}
{...props}
sx={{
bgcolor: (theme) => theme.palette.background.paperLight,
borderRadius: "6px",
...props.sx,
}}
/>
)
}
export const SearchFieldSkeleton = () => <BaseSkeleton width="100%" />
export const MenuSkeleton = () => (
<BaseSkeleton sx={{ minWidth: 200, flexShrink: 0 }} />
)
export const Filter = ({
filter,
isLoading,
error,
skeleton,
options,
}: {
filter: ReturnType<typeof useFilter>
skeleton: ReactNode
isLoading: boolean
error?: unknown
options?: ReactNode
}) => {
const shouldDisplayError = hasError(error) && isApiValidationError(error)
const hasFilterQuery = filter.query !== ""
const [searchQuery, setSearchQuery] = useState(filter.query)
useEffect(() => {
setSearchQuery(filter.query)
}, [filter.query])
return (
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
{isLoading ? (
skeleton
) : (
<>
<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>
),
}}
/>
{options}
</>
)}
</Box>
)
}
export const FilterMenu = <TOption extends BaseOption>({
id,
menu,
label,
children,
}: {
menu: ReturnType<typeof useFilterMenu<TOption>>
label: ReactNode
id: string
children: (values: { option: TOption; isSelected: boolean }) => ReactNode
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ minWidth: 200 }}
>
{label}
</MenuButton>
<Menu
id={id}
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,
}}
>
{menu.searchOptions?.map((option) => (
<MenuItem
key={option.label}
selected={option.value === menu.selectedOption?.value}
onClick={() => {
menu.selectOption(option)
handleClose()
}}
>
{children({
option,
isSelected: option.value === menu.selectedOption?.value,
})}
</MenuItem>
))}
</Menu>
</div>
)
}
export const FilterSearchMenu = <TOption extends BaseOption>({
id,
menu,
label,
children,
}: {
menu: ReturnType<typeof useFilterMenu<TOption>>
label: ReactNode
id: string
children: (values: { option: TOption; isSelected: boolean }) => ReactNode
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ minWidth: 200 }}
>
{label}
</MenuButton>
<SearchMenu
id={id}
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
options={menu.searchOptions}
query={menu.query}
onQueryChange={menu.setQuery}
renderOption={(option) => (
<MenuItem
key={option.label}
selected={option.value === menu.selectedOption?.value}
onClick={() => {
menu.selectOption(option)
handleClose()
}}
>
{children({
option,
isSelected: option.value === menu.selectedOption?.value,
})}
</MenuItem>
)}
/>
</div>
)
}
type OptionItemProps = {
option: BaseOption
left?: ReactNode
isSelected?: boolean
}
export 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>
)
}
+103
View File
@@ -0,0 +1,103 @@
import { useMemo, useRef, useState } from "react"
import { BaseOption } from "./options"
import { useQuery } from "@tanstack/react-query"
export type UseFilterMenuOptions<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
}
export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
id,
value,
getSelectedOption,
getOptions,
onChange,
enabled,
}: UseFilterMenuOptions<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", query],
queryFn: () => getOptions(query),
enabled,
})
const searchOptions = useMemo(() => {
const isDataLoaded =
searchOptionsQuery.isFetched && selectedOptionQuery.isFetched
if (!isDataLoaded) {
return undefined
}
let options = searchOptionsQuery.data ?? []
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,
}
}
+4
View File
@@ -0,0 +1,4 @@
export type BaseOption = {
label: string
value: string
}
@@ -0,0 +1,44 @@
import Box from "@mui/material/Box"
import Skeleton from "@mui/material/Skeleton"
type BasePaginationStatusProps = {
label: string
isLoading: boolean
showing?: number
total?: number
}
type LoadedPaginationStatusProps = BasePaginationStatusProps & {
isLoading: false
showing: number
total: number
}
export const PaginationStatus = ({
isLoading,
showing,
total,
label,
}: BasePaginationStatusProps | LoadedPaginationStatusProps) => {
return (
<Box
sx={{
fontSize: 13,
mb: 2,
mt: 1,
color: (theme) => theme.palette.text.secondary,
"& strong": { color: (theme) => theme.palette.text.primary },
}}
>
{!isLoading ? (
<>
Showing <strong>{showing}</strong> of <strong>{total}</strong> {label}
</>
) : (
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
<Skeleton variant="text" width={160} height={16} />
</Box>
)}
</Box>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { FC } from "react"
import Box from "@mui/material/Box"
import { Palette, PaletteColor } from "@mui/material/styles"
import {
Filter,
FilterMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
useFilter,
} from "components/Filter/filter"
import { BaseOption } from "components/Filter/options"
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
type StatusOption = BaseOption & {
color: string
}
export const useStatusFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
const statusOptions: StatusOption[] = [
{ value: "active", label: "Active", color: "success" },
{ value: "suspended", label: "Suspended", color: "secondary" },
]
return useFilterMenu({
onChange,
value,
id: "status",
getSelectedOption: async () =>
statusOptions.find((option) => option.value === value) ?? null,
getOptions: async () => statusOptions,
})
}
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>
export const UsersFilter = ({
filter,
error,
menus,
}: {
filter: ReturnType<typeof useFilter>
error?: unknown
menus: {
status: StatusFilterMenu
}
}) => {
return (
<Filter
isLoading={menus.status.isInitializing}
filter={filter}
error={error}
options={<StatusMenu {...menus.status} />}
skeleton={
<>
<SearchFieldSkeleton />
<MenuSkeleton />
</>
}
/>
)
}
const StatusMenu = (menu: StatusFilterMenu) => {
return (
<FilterMenu
id="status-menu"
menu={menu}
label={
menu.selectedOption ? (
<StatusOptionItem option={menu.selectedOption} />
) : (
"All statuses"
)
}
>
{(itemProps) => <StatusOptionItem {...itemProps} />}
</FilterMenu>
)
}
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,
}}
/>
)
}
+41 -6
View File
@@ -7,7 +7,7 @@ import {
} from "components/PaginationWidget/utils"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
import { FC, ReactNode } from "react"
import { FC, ReactNode, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate } from "react-router"
import { useSearchParams } from "react-router-dom"
@@ -17,6 +17,9 @@ import { ConfirmDialog } from "../../components/Dialogs/ConfirmDialog/ConfirmDia
import { ResetPasswordDialog } from "../../components/Dialogs/ResetPasswordDialog/ResetPasswordDialog"
import { pageTitle } from "../../utils/page"
import { UsersPageView } from "./UsersPageView"
import { useStatusFilterMenu } from "./UsersFilter"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFilter } from "components/Filter/filter"
export const Language = {
suspendDialogTitle: "Suspend user",
@@ -32,7 +35,8 @@ const getSelectedUser = (id: string, users?: User[]) =>
export const UsersPage: FC<{ children?: ReactNode }> = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const searchParamsResult = useSearchParams()
const [searchParams, setSearchParams] = searchParamsResult
const filter = searchParams.get("filter") ?? ""
const [usersState, usersSend] = useMachine(usersMachine, {
context: {
@@ -73,6 +77,26 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const me = useMe()
// New filter
const dashboard = useDashboard()
const useFilterResult = useFilter({
searchParamsResult,
onUpdate: () => {
usersSend({ type: "UPDATE_PAGE", page: "1" })
},
})
useEffect(() => {
usersSend({ type: "UPDATE_FILTER", query: useFilterResult.query })
}, [useFilterResult.query, usersSend])
const statusMenu = useStatusFilterMenu({
value: useFilterResult.values.status,
onChange: (option) =>
useFilterResult.update({
...useFilterResult.values,
status: option?.value,
}),
})
return (
<>
<Helmet>
@@ -123,13 +147,24 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
isLoading={isLoading}
canEditUsers={canEditUsers}
filter={usersState.context.filter}
onFilter={(query) => {
usersSend({ type: "UPDATE_FILTER", query })
}}
paginationRef={paginationRef}
isNonInitialPage={nonInitialPage(searchParams)}
actorID={me.id}
filterProps={
dashboard.experiments.includes("workspace_filter")
? {
filter: useFilterResult,
menus: {
status: statusMenu,
},
}
: {
filter: usersState.context.filter,
onFilter: (query) => {
usersSend({ type: "UPDATE_FILTER", query })
},
}
}
/>
<DeleteDialog
@@ -1,4 +1,4 @@
import { ComponentMeta, Story } from "@storybook/react"
import { Meta, StoryObj } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import {
MockUser,
@@ -6,9 +6,10 @@ import {
MockAssignableSiteRoles,
mockApiError,
} from "testHelpers/entities"
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
import { UsersPageView } from "./UsersPageView"
import { action } from "@storybook/addon-actions"
export default {
const meta: Meta<typeof UsersPageView> = {
title: "pages/UsersPageView",
component: UsersPageView,
args: {
@@ -17,39 +18,50 @@ export default {
users: [MockUser, MockUser2],
roles: MockAssignableSiteRoles,
canEditUsers: true,
filterProps: {
onFilter: action("onFilter"),
filter: "",
},
},
} as ComponentMeta<typeof UsersPageView>
const Template: Story<UsersPageViewProps> = (args) => (
<UsersPageView {...args} />
)
export const Admin = Template.bind({})
export const SmallViewport = Template.bind({})
SmallViewport.parameters = {
chromatic: { viewports: [600] },
}
export const Member = Template.bind({})
Member.args = { canEditUsers: false }
export default meta
type Story = StoryObj<typeof UsersPageView>
export const Empty = Template.bind({})
Empty.args = { users: [] }
export const Admin: Story = {}
export const EmptyPage = Template.bind({})
EmptyPage.args = { users: [], isNonInitialPage: true }
export const Error = Template.bind({})
Error.args = {
users: undefined,
error: mockApiError({
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
],
}),
export const SmallViewport = {
parameters: {
chromatic: { viewports: [600] },
},
}
export const Member = {
args: { canEditUsers: false },
}
export const Empty = {
args: { users: [] },
}
export const EmptyPage = {
args: {
users: [],
isNonInitialPage: true,
},
}
export const Error = {
args: {
users: undefined,
error: mockApiError({
message: "Invalid user search query.",
validations: [
{
field: "status",
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
},
],
}),
},
}
+22 -10
View File
@@ -1,10 +1,12 @@
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
import { FC } from "react"
import { ComponentProps, FC } from "react"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import * as TypesGen from "../../api/typesGenerated"
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
import { UsersTable } from "../../components/UsersTable/UsersTable"
import { userFilterQuery } from "../../utils/filters"
import { UsersFilter } from "./UsersFilter"
import { PaginationStatus } from "components/PaginationStatus/PaginationStatus"
export const Language = {
activeUsersFilterName: "Active users",
@@ -14,7 +16,6 @@ export interface UsersPageViewProps {
users?: TypesGen.User[]
count?: number
roles?: TypesGen.AssignableRoles[]
filter?: string
error?: unknown
isUpdatingUserRoles?: boolean
canEditUsers?: boolean
@@ -28,7 +29,9 @@ export interface UsersPageViewProps {
user: TypesGen.User,
roles: TypesGen.Role["name"][],
) => void
onFilter: (query: string) => void
filterProps:
| ComponentProps<typeof SearchBarWithFilter>
| ComponentProps<typeof UsersFilter>
paginationRef: PaginationMachineRef
isNonInitialPage: boolean
actorID: string
@@ -48,8 +51,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
isUpdatingUserRoles,
canEditUsers,
isLoading,
filter,
onFilter,
filterProps,
paginationRef,
isNonInitialPage,
actorID,
@@ -61,11 +63,21 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
return (
<>
<SearchBarWithFilter
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
error={error}
{"onFilter" in filterProps ? (
<SearchBarWithFilter
{...filterProps}
presetFilters={presetFilters}
error={error}
/>
) : (
<UsersFilter {...filterProps} />
)}
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={users?.length}
total={count}
label="users"
/>
<UsersTable
@@ -4,17 +4,18 @@ import { Helmet } from "react-helmet-async"
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 { useMe, useOrganizationId, usePermissions } from "hooks"
import {
useUsersAutocomplete,
useTemplatesAutocomplete,
useStatusAutocomplete,
} from "./filter/autocompletes"
useUserFilterMenu,
useTemplateFilterMenu,
useStatusFilterMenu,
} from "./filter/menus"
import { useSearchParams } from "react-router-dom"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFilter } from "components/Filter/filter"
const WorkspacesPage: FC = () => {
const me = useMe()
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
@@ -22,6 +23,7 @@ const WorkspacesPage: FC = () => {
const searchParamsResult = useSearchParams()
const pagination = usePagination({ searchParamsResult })
const filter = useFilter({
initialValue: `owner:${me.username}`,
searchParamsResult,
onUpdate: () => {
pagination.goToPage(1)
@@ -34,20 +36,23 @@ const WorkspacesPage: FC = () => {
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(
const userMenu = useUserFilterMenu({
value: filter.values.owner,
onChange: (option) =>
filter.update({ ...filter.values, owner: option?.value }),
enabled: canFilterByUser,
})
const templateMenu = useTemplateFilterMenu({
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 }),
)
value: filter.values.template,
onChange: (option) =>
filter.update({ ...filter.values, template: option?.value }),
})
const statusMenu = useStatusFilterMenu({
value: filter.values.status,
onChange: (option) =>
filter.update({ ...filter.values, status: option?.value }),
})
const dashboard = useDashboard()
return (
@@ -65,10 +70,10 @@ const WorkspacesPage: FC = () => {
limit={pagination.limit}
filterProps={{
filter,
autocomplete: {
users: canFilterByUser ? usersAutocomplete : undefined,
templates: templatesAutocomplete,
status: statusAutocomplete,
menus: {
user: canFilterByUser ? userMenu : undefined,
template: templateMenu,
status: statusMenu,
},
}}
onPageChange={pagination.goToPage}
@@ -71,7 +71,7 @@ const MockedAppearance = {
save: () => null,
}
const mockAutocomplete = {
const mockMenu = {
initialOption: undefined,
isInitializing: false,
isSearching: false,
@@ -87,16 +87,17 @@ const defaultFilterProps = {
query: `owner:${MockUser.username}`,
update: () => action("update"),
debounceUpdate: action("debounce") as any,
used: false,
values: {
owner: MockUser.username,
template: undefined,
status: undefined,
},
},
autocomplete: {
users: mockAutocomplete,
templates: mockAutocomplete,
status: mockAutocomplete,
menus: {
user: mockMenu,
template: mockMenu,
status: mockMenu,
},
} as ComponentProps<typeof WorkspacesPageView>["filterProps"]
@@ -148,6 +149,7 @@ export const NoSearchResults: Story = {
filter: {
...defaultFilterProps.filter,
query: "searchwithnoresults",
used: true,
},
},
count: 0,
@@ -17,12 +17,11 @@ 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 { WorkspacesFilter } from "./filter/filter"
import { hasError, isApiValidationError } from "api/errors"
import { workspaceFilterQuery } from "utils/filters"
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
import Box from "@mui/material/Box"
import Skeleton from "@mui/material/Skeleton"
import { PaginationStatus } from "components/PaginationStatus/PaginationStatus"
export const Language = {
pageTitle: "Workspaces",
@@ -53,7 +52,7 @@ export interface WorkspacesPageViewProps {
useNewFilter?: boolean
page: number
limit: number
filterProps: ComponentProps<typeof Filter>
filterProps: ComponentProps<typeof WorkspacesFilter>
onPageChange: (page: number) => void
onUpdateWorkspace: (workspace: Workspace) => void
}
@@ -135,7 +134,7 @@ export const WorkspacesPageView: FC<
/>
{useNewFilter ? (
<Filter error={error} {...filterProps} />
<WorkspacesFilter error={error} {...filterProps} />
) : (
<SearchBarWithFilter
filter={filterProps.filter.query}
@@ -146,33 +145,16 @@ export const WorkspacesPageView: FC<
)}
</Stack>
<Box
sx={{
fontSize: 13,
mb: 2,
mt: 1,
color: (theme) => theme.palette.text.secondary,
"& strong": { color: (theme) => theme.palette.text.primary },
}}
>
{workspaces ? (
<>
Showing <strong>{workspaces?.length}</strong> of{" "}
<strong>{count}</strong> workspaces
</>
) : (
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
<Skeleton variant="text" width={160} height={16} />
</Box>
)}
</Box>
<PaginationStatus
isLoading={!workspaces}
showing={workspaces?.length}
total={count}
label="workspaces"
/>
<WorkspacesTable
workspaces={workspaces}
isUsingFilter={
filterProps.filter.query !== "" &&
filterProps.filter.query !== workspaceFilterQuery.me
}
isUsingFilter={filterProps.filter.used}
onUpdateWorkspace={onUpdateWorkspace}
error={error}
/>
@@ -1,226 +0,0 @@
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"
import { useMe } from "hooks"
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", query],
queryFn: () => getOptions(query),
enabled,
})
const searchOptions = useMemo(() => {
const isDataLoaded =
searchOptionsQuery.isFetched && selectedOptionQuery.isFetched
if (!isDataLoaded) {
return undefined
}
let options = searchOptionsQuery.data ?? []
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,
) => {
const me = useMe()
const addMeAsFirstOption = (options: OwnerOption[]) => {
options = options.filter((option) => option.value !== me.username)
return [
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
...options,
]
}
return 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 })
let options: OwnerOption[] = usersRes.users.map((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
}))
options = addMeAsFirstOption(options)
return options
},
})
}
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>
+78 -518
View File
@@ -1,274 +1,72 @@
import { FC, ReactNode, forwardRef, useEffect, useRef, useState } from "react"
import { FC } 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 { UserFilterMenu, TemplateFilterMenu, StatusFilterMenu } from "./menus"
import { UserOption, TemplateOption, StatusOption } from "./options"
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"
import { workspaceFilterQuery } from "utils/filters"
Filter,
FilterMenu,
FilterSearchMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
useFilter,
} from "components/Filter/filter"
export type FilterValues = {
owner?: string // User["username"]
status?: string // WorkspaceStatus
template?: string // Template["name"]
}
export const useFilter = ({
onUpdate,
searchParamsResult,
export const WorkspacesFilter = ({
filter,
error,
menus,
}: {
searchParamsResult: ReturnType<typeof useSearchParams>
onUpdate?: () => void
filter: ReturnType<typeof useFilter>
error?: unknown
menus: {
user?: UserFilterMenu
template: TemplateFilterMenu
status: StatusFilterMenu
}
}) => {
const [searchParams, setSearchParams] = searchParamsResult
const query = searchParams.get("filter") ?? workspaceFilterQuery.me
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,
}}
<Filter
isLoading={menus.status.isInitializing}
filter={filter}
error={error}
options={
<>
{menus.user && <UserMenu {...menus.user} />}
<TemplateMenu {...menus.template} />
<StatusMenu {...menus.status} />
</>
}
skeleton={
<>
<SearchFieldSkeleton />
{menus.user && <MenuSkeleton />}
<MenuSkeleton />
<MenuSkeleton />
</>
}
/>
)
}
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>
)
}
const UserMenu = (menu: UserFilterMenu) => {
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} />
<FilterSearchMenu
id="users-menu"
menu={menu}
label={
menu.selectedOption ? (
<UserOptionItem option={menu.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>
)
}
>
{(itemProps) => <UserOptionItem {...itemProps} />}
</FilterSearchMenu>
)
}
@@ -276,7 +74,7 @@ const UserOptionItem = ({
option,
isSelected,
}: {
option: OwnerOption
option: UserOption
isSelected?: boolean
}) => {
return (
@@ -294,56 +92,21 @@ const UserOptionItem = ({
)
}
const TemplatesFilter = ({
autocomplete,
}: {
autocomplete: TemplatesAutocomplete
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
const TemplateMenu = (menu: TemplateFilterMenu) => {
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<TemplateOptionItem option={autocomplete.selectedOption} />
<FilterSearchMenu
id="templates-menu"
menu={menu}
label={
menu.selectedOption ? (
<TemplateOptionItem option={menu.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>
)
}
>
{(itemProps) => <TemplateOptionItem {...itemProps} />}
</FilterSearchMenu>
)
}
@@ -379,62 +142,21 @@ const TemplateAvatar: FC<
)
}
const StatusFilter = ({
autocomplete,
}: {
autocomplete: StatusAutocomplete
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleClose = () => {
setIsMenuOpen(false)
}
const StatusMenu = (menu: StatusFilterMenu) => {
return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<StatusOptionItem option={autocomplete.selectedOption} />
<FilterMenu
id="status-menu"
menu={menu}
label={
menu.selectedOption ? (
<StatusOptionItem option={menu.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>
)
}
>
{(itemProps) => <StatusOptionItem {...itemProps} />}
</FilterMenu>
)
}
@@ -467,165 +189,3 @@ const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => {
/>
)
}
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,126 @@
import { UserOption, StatusOption, TemplateOption } from "./options"
import { getTemplates, getUsers } from "api/api"
import { WorkspaceStatuses } from "api/typesGenerated"
import { getDisplayWorkspaceStatus } from "utils/workspace"
import { useMe } from "hooks"
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
export const useUserFilterMenu = ({
value,
onChange,
enabled,
}: Pick<
UseFilterMenuOptions<UserOption>,
"value" | "onChange" | "enabled"
>) => {
const me = useMe()
const addMeAsFirstOption = (options: UserOption[]) => {
options = options.filter((option) => option.value !== me.username)
return [
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
...options,
]
}
return useFilterMenu({
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 })
let options: UserOption[] = usersRes.users.map((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
}))
options = addMeAsFirstOption(options)
return options
},
})
}
export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>
export const useTemplateFilterMenu = ({
value,
onChange,
orgId,
}: { orgId: string } & Pick<
UseFilterMenuOptions<TemplateOption>,
"value" | "onChange"
>) => {
return useFilterMenu({
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 TemplateFilterMenu = ReturnType<typeof useTemplateFilterMenu>
export const useStatusFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
const statusOptions = WorkspaceStatuses.map((status) => {
const display = getDisplayWorkspaceStatus(status)
return {
label: display.text,
value: status,
color: display.type ?? "warning",
} as StatusOption
})
return useFilterMenu({
onChange,
value,
id: "status",
getSelectedOption: async () =>
statusOptions.find((option) => option.value === value) ?? null,
getOptions: async () => statusOptions,
})
}
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>
@@ -1,9 +1,6 @@
export type BaseOption = {
label: string
value: string
}
import { BaseOption } from "components/Filter/options"
export type OwnerOption = BaseOption & {
export type UserOption = BaseOption & {
avatarUrl?: string
}