8000 feat(site): add new filter to the users page by BrunoQuaresma · Pull Request #7818 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Improve users filter
  • Loading branch information
BrunoQuaresma committed Jun 2, 2023
commit 7eb514212e8eec3ad147b741e4ef8539dee68c0d
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC, ReactNode, forwardRef, useEffect, useRef, useState } from "react"
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"
Expand All @@ -7,7 +7,6 @@ import Menu from "@mui/material/Menu"
import MenuItem from "@mui/material/MenuItem"
import SearchOutlined from "@mui/icons-material/SearchOutlined"
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"
Expand All @@ -19,13 +18,11 @@ import {
hasError,
isApiValidationError,
} from "api/errors"
import { StatusAutocomplete } from "./autocompletes"
import { StatusOption, BaseOption } from "./options"
import { useFilterMenu } from "./menu"
import { BaseOption } from "./options"
import debounce from "just-debounce-it"

export type FilterValues = {
status?: string
}
type FilterValues = Record<string, string | undefined>

export const useFilter = ({
onUpdate,
Expand Down Expand Up @@ -88,7 +85,7 @@ const stringifyFilter = (filterValue: FilterValues): string => {
let result = ""

for (const key in filterValue) {
const value = filterValue[key as keyof FilterValues]
const value = filterValue[key]
if (value) {
result += `${key}:${value} `
}
Expand All @@ -97,7 +94,7 @@ const stringifyFilter = (filterValue: FilterValues): string => {
return result.trim()
}

const FilterSkeleton = (props: SkeletonProps) => {
const BaseSkeleton = (props: SkeletonProps) => {
return (
<Skeleton
variant="rectangular"
Expand All @@ -112,94 +109,103 @@ const FilterSkeleton = (props: SkeletonProps) => {
)
}

export const SearchFieldSkeleton = () => <BaseSkeleton width="100%" />
export const MenuSkeleton = () => (
<BaseSkeleton width="200px" sx={{ flexShrink: 0 }} />
)

export const Filter = ({
filter,
autocomplete,
isLoading,
error,
skeleton,
options,
}: {
filter: ReturnType<typeof useFilter>
skeleton: ReactNode
isLoading: boolean
error?: unknown
autocomplete: {
status: StatusAutocomplete
}
options?: ReactNode
}) => {
const shouldDisplayError = hasError(error) && isApiValidationError(error)
const hasFilterQuery = filter.query !== ""
const isIinitializingFilters = autocomplete.status.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%" />
<FilterSkeleton width="200px" sx={{ flexShrink: 0 }} />
</Box>
)
}

return (
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
<TextField
fullWidth
error={shouldDisplayError}
helperText={
shouldDisplayError ? getValidationErrorMessage(error) : undefined
}
size="small"
InputProps={{
name: "query",
placeholder: "Search...",
value: searchQuery,
onChange: (e) => {
setSearchQuery(e.target.value)
filter.debounceUpdate(e.target.value)
},
sx: {
borderRadius: "6px",
"& input::placeholder": {
color: (theme) => theme.palette.text.secondary,
},
},
startAdornment: (
<InputAdornment position="start">
<SearchOutlined
sx={{
fontSize: 14,
{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,
}}
/>
</InputAdornment>
),
endAdornment: hasFilterQuery && (
<InputAdornment position="end">
<Tooltip title="Clear filter">
<IconButton
size="small"
onClick={() => {
filter.update("")
}}
>
<CloseOutlined sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
},
},
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>
),
}}
/>

<StatusFilter autocomplete={autocomplete.status} />
{options}
</>
)}
</Box>
)
}

const StatusFilter = ({
autocomplete,
export const FilterMenu = <TOption extends BaseOption>({
id,
menu,
label,
children,
}: {
autocomplete: StatusAutocomplete
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)
Expand All @@ -215,14 +221,10 @@ const StatusFilter = ({
onClick={() => setIsMenuOpen(true)}
sx={{ width: 200 }}
>
{autocomplete.selectedOption ? (
<StatusOptionItem option={autocomplete.selectedOption} />
) : (
"All statuses"
)}
{label}
</MenuButton>
<Menu
id="status-filter-menu"
id={id}
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
Expand All @@ -235,63 +237,33 @@ const StatusFilter = ({
exit: 0,
}}
>
{autocomplete.searchOptions?.map((option) => (
{menu.searchOptions?.map((option) => (
<MenuItem
key={option.label}
selected={option.value === autocomplete.selectedOption?.value}
selected={option.value === menu.selectedOption?.value}
onClick={() => {
autocomplete.selectOption(option)
menu.selectOption(option)
handleClose()
}}
>
<StatusOptionItem
option={option}
isSelected={option.value === autocomplete.selectedOption?.value}
/>
{children({
option,
isSelected: option.value === menu.selectedOption?.value,
})}
</MenuItem>
))}
</Menu>
</div>
)
}

const StatusOptionItem = ({
option,
isSelected,
}: {
option: StatusOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
left={<StatusIndicator option={option} />}
isSelected={isSelected}
/>
)
}

const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => {
return (
<Box
height={8}
width={8}
borderRadius={9999}
sx={{
backgroundColor: (theme) =>
(theme.palette[option.color as keyof Palette] as PaletteColor).light,
}}
/>
)
}

type OptionItemProps = {
option: BaseOption
left?: ReactNode
isSelected?: boolean
}

const OptionItem = ({ option, left, isSelected }: OptionItemProps) => {
export const OptionItem = ({ option, left, isSelected }: OptionItemProps) => {
return (
<Box
display="flex"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMemo, useRef, useState } from "react"
import { BaseOption, StatusOption } from "./options"
import { BaseOption } from "./options"
import { useQuery } from "@tanstack/react-query"

type UseAutocompleteOptions<TOption extends BaseOption> = {
export type UseFilterMenuOptions<TOption extends BaseOption> = {
id: string
value: string | undefined
// Using null because of react-query
Expand All @@ -13,14 +13,14 @@ type UseAutocompleteOptions<TOption extends BaseOption> = {
enabled?: boolean
}

const useAutocomplete = <TOption extends BaseOption = BaseOption>({
export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
id,
value,
getSelectedOption,
getOptions,
onChange,
enabled,
}: UseAutocompleteOptions<TOption>) => {
}: UseFilterMenuOptions<TOption>) => {
const selectedOptionsCacheRef = useRef<Record<string, TOption>>({})
const [query, setQuery] = useState("")
const selectedOptionQuery = useQuery({
Expand Down Expand Up @@ -101,23 +101,3 @@ const useAutocomplete = <TOption extends BaseOption = BaseOption>({
isSearching: searchOptionsQuery.isFetching,
}
}

export const useStatusAutocomplete = (
value: string | undefined,
onChange: (option: StatusOption | undefined) => void,
) => {
const statusOptions: StatusOption[] = [
{ value: "active", label: "Active", color: "success" },
{ value: "suspended", label: "Suspended", color: "secondary" },
]
return useAutocomplete({
onChange,
value,
id: "status",
getSelectedOption: async () =>
statusOptions.find((option) => option.value === value) ?? null,
getOptions: async () => statusOptions,
})
}

export type StatusAutocomplete = ReturnType<typeof useStatusAutocomplete>
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,3 @@ export type BaseOption = {
label: string
value: string
}

export type StatusOption = BaseOption & {
color: string
}
Loading
0