mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add new filter to the users page (#7818)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user