Skip to content

Commit 7eb5142

Browse files
committed
Improve users filter
1 parent fdcc802 commit 7eb5142

File tree

6 files changed

+216
-156
lines changed

6 files changed

+216
-156
lines changed

site/src/pages/UsersPage/filter/filter.tsx renamed to site/src/components/Filter/filter.tsx

Lines changed: 89 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FC, ReactNode, forwardRef, useEffect, useRef, useState } from "react"
1+
import { ReactNode, forwardRef, useEffect, useRef, useState } from "react"
22
import Box from "@mui/material/Box"
33
import TextField from "@mui/material/TextField"
44
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"
@@ -7,7 +7,6 @@ import Menu from "@mui/material/Menu"
77
import MenuItem from "@mui/material/MenuItem"
88
import SearchOutlined from "@mui/icons-material/SearchOutlined"
99
import InputAdornment from "@mui/material/InputAdornment"
10-
import { Palette, PaletteColor } from "@mui/material/styles"
1110
import IconButton from "@mui/material/IconButton"
1211
import Tooltip from "@mui/material/Tooltip"
1312
import CloseOutlined from "@mui/icons-material/CloseOutlined"
@@ -19,13 +18,11 @@ import {
1918
hasError,
2019
isApiValidationError,
2120
} from "api/errors"
22-
import { StatusAutocomplete } from "./autocompletes"
23-
import { StatusOption, BaseOption } from "./options"
21+
import { useFilterMenu } from "./menu"
22+
import { BaseOption } from "./options"
2423
import debounce from "just-debounce-it"
2524

26-
export type FilterValues = {
27-
status?: string
28-
}
25+
type FilterValues = Record<string, string | undefined>
2926

3027
export const useFilter = ({
3128
onUpdate,
@@ -88,7 +85,7 @@ const stringifyFilter = (filterValue: FilterValues): string => {
8885
let result = ""
8986

9087
for (const key in filterValue) {
91-
const value = filterValue[key as keyof FilterValues]
88+
const value = filterValue[key]
9289
if (value) {
9390
result += `${key}:${value} `
9491
}
@@ -97,7 +94,7 @@ const stringifyFilter = (filterValue: FilterValues): string => {
9794
return result.trim()
9895
}
9996

100-
const FilterSkeleton = (props: SkeletonProps) => {
97+
const BaseSkeleton = (props: SkeletonProps) => {
10198
return (
10299
<Skeleton
103100
variant="rectangular"
@@ -112,94 +109,103 @@ const FilterSkeleton = (props: SkeletonProps) => {
112109
)
113110
}
114111

112+
export const SearchFieldSkeleton = () => <BaseSkeleton width="100%" />
113+
export const MenuSkeleton = () => (
114+
<BaseSkeleton width="200px" sx={{ flexShrink: 0 }} />
115+
)
116+
115117
export const Filter = ({
116118
filter,
117-
autocomplete,
119+
isLoading,
118120
error,
121+
skeleton,
122+
options,
119123
}: {
120124
filter: ReturnType<typeof useFilter>
125+
skeleton: ReactNode
126+
isLoading: boolean
121127
error?: unknown
122-
autocomplete: {
123-
status: StatusAutocomplete
124-
}
128+
options?: ReactNode
125129
}) => {
126130
const shouldDisplayError = hasError(error) && isApiValidationError(error)
127131
const hasFilterQuery = filter.query !== ""
128-
const isIinitializingFilters = autocomplete.status.isInitializing
129132
const [searchQuery, setSearchQuery] = useState(filter.query)
130133

131134
useEffect(() => {
132135
setSearchQuery(filter.query)
133136
}, [filter.query])
134137

135-
if (isIinitializingFilters) {
136-
return (
137-
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
138-
<FilterSkeleton width="100%" />
139-
<FilterSkeleton width="200px" sx={{ flexShrink: 0 }} />
140-
</Box>
141-
)
142-
}
143-
144138
return (
145139
<Box display="flex" sx={{ gap: 1, mb: 2 }}>
146-
<TextField
147-
fullWidth
148-
error={shouldDisplayError}
149-
helperText={
150-
shouldDisplayError ? getValidationErrorMessage(error) : undefined
151-
}
152-
size="small"
153-
InputProps={{
154-
name: "query",
155-
placeholder: "Search...",
156-
value: searchQuery,
157-
onChange: (e) => {
158-
setSearchQuery(e.target.value)
159-
filter.debounceUpdate(e.target.value)
160-
},
161-
sx: {
162-
borderRadius: "6px",
163-
"& input::placeholder": {
164-
color: (theme) => theme.palette.text.secondary,
165-
},
166-
},
167-
startAdornment: (
168-
<InputAdornment position="start">
169-
<SearchOutlined
170-
sx={{
171-
fontSize: 14,
140+
{isLoading ? (
141+
skeleton
142+
) : (
143+
<>
144+
<TextField
145+
fullWidth
146+
error={shouldDisplayError}
147+
helperText={
148+
shouldDisplayError ? getValidationErrorMessage(error) : undefined
149+
}
150+
size="small"
151+
InputProps={{
152+
name: "query",
153+
placeholder: "Search...",
154+
value: searchQuery,
155+
onChange: (e) => {
156+
setSearchQuery(e.target.value)
157+
filter.debounceUpdate(e.target.value)
158+
},
159+
sx: {
160+
borderRadius: "6px",
161+
"& input::placeholder": {
172162
color: (theme) => theme.palette.text.secondary,
173-
}}
174-
/>
175-
</InputAdornment>
176-
),
177-
endAdornment: hasFilterQuery && (
178-
<InputAdornment position="end">
179-
<Tooltip title="Clear filter">
180-
<IconButton
181-
size="small"
182-
onClick={() => {
183-
filter.update("")
184-
}}
185-
>
186-
<CloseOutlined sx={{ fontSize: 14 }} />
187-
</IconButton>
188-
</Tooltip>
189-
</InputAdornment>
190-
),
191-
}}
192-
/>
163+
},
164+
},
165+
startAdornment: (
166+
<InputAdornment position="start">
167+
<SearchOutlined
168+
sx={{
169+
fontSize: 14,
170+
color: (theme) => theme.palette.text.secondary,
171+
}}
172+
/>
173+
</InputAdornment>
174+
),
175+
endAdornment: hasFilterQuery && (
176+
<InputAdornment position="end">
177+
<Tooltip title="Clear filter">
178+
<IconButton
179+
size="small"
180+
onClick={() => {
181+
filter.update("")
182+
}}
183+
>
184+
<CloseOutlined sx={{ fontSize: 14 }} />
185+
</IconButton>
186+
</Tooltip>
187+
</InputAdornment>
188+
),
189+
}}
190+
/>
193191

194-
<StatusFilter autocomplete={autocomplete.status} />
192+
{options}
193+
</>
194+
)}
195195
</Box>
196196
)
197197
}
198198

199-
const StatusFilter = ({
200-
autocomplete,
199+
export const FilterMenu = <TOption extends BaseOption>({
200+
id,
201+
menu,
202+
label,
203+
children,
201204
}: {
202-
autocomplete: StatusAutocomplete
205+
menu: ReturnType<typeof useFilterMenu<TOption>>
206+
label: ReactNode
207+
id: string
208+
children: (values: { option: TOption; isSelected: boolean }) => ReactNode
203209
}) => {
204210
const buttonRef = useRef<HTMLButtonElement>(null)
205211
const [isMenuOpen, setIsMenuOpen] = useState(false)
@@ -215,14 +221,10 @@ const StatusFilter = ({
215221
onClick={() => setIsMenuOpen(true)}
216222
sx={{ width: 200 }}
217223
>
218-
{autocomplete.selectedOption ? (
219-
<StatusOptionItem option={autocomplete.selectedOption} />
220-
) : (
221-
"All statuses"
222-
)}
224+
{label}
223225
</MenuButton>
224226
<Menu
225-
id="status-filter-menu"
227+
id={id}
226228
anchorEl={buttonRef.current}
227229
open={isMenuOpen}
228230
onClose={handleClose}
@@ -235,63 +237,33 @@ const StatusFilter = ({
235237
exit: 0,
236238
}}
237239
>
238-
{autocomplete.searchOptions?.map((option) => (
240+
{menu.searchOptions?.map((option) => (
239241
<MenuItem
240242
key={option.label}
241-
selected={option.value === autocomplete.selectedOption?.value}
243+
selected={option.value === menu.selectedOption?.value}
242244
onClick={() => {
243-
autocomplete.selectOption(option)
245+
menu.selectOption(option)
244246
handleClose()
245247
}}
246248
>
247-
<StatusOptionItem
248-
option={option}
249-
isSelected={option.value === autocomplete.selectedOption?.value}
250-
/>
249+
{children({
250+
option,
251+
isSelected: option.value === menu.selectedOption?.value,
252+
})}
251253
</MenuItem>
252254
))}
253255
</Menu>
254256
</div>
255257
)
256258
}
257259

258-
const StatusOptionItem = ({
259-
option,
260-
isSelected,
261-
}: {
262-
option: StatusOption
263-
isSelected?: boolean
264-
}) => {
265-
return (
266-
<OptionItem
267-
option={option}
268-
left={<StatusIndicator option={option} />}
269-
isSelected={isSelected}
270-
/>
271-
)
272-
}
273-
274-
const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => {
275-
return (
276-
<Box
277-
height={8}
278-
width={8}
279-
borderRadius={9999}
280-
sx={{
281-
backgroundColor: (theme) =>
282-
(theme.palette[option.color as keyof Palette] as PaletteColor).light,
283-
}}
284-
/>
285-
)
286-
}
287-
288260
type OptionItemProps = {
289261
option: BaseOption
290262
left?: ReactNode
291263
isSelected?: boolean
292264
}
293265

294-
const OptionItem = ({ option, left, isSelected }: OptionItemProps) => {
266+
export const OptionItem = ({ option, left, isSelected }: OptionItemProps) => {
295267
return (
296268
<Box
297269
display="flex"

site/src/pages/UsersPage/filter/autocompletes.ts renamed to site/src/components/Filter/menu.ts

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useMemo, useRef, useState } from "react"
2-
import { BaseOption, StatusOption } from "./options"
2+
import { BaseOption } from "./options"
33
import { useQuery } from "@tanstack/react-query"
44

5-
type UseAutocompleteOptions<TOption extends BaseOption> = {
5+
export type UseFilterMenuOptions<TOption extends BaseOption> = {
66
id: string
77
value: string | undefined
88
// Using null because of react-query
@@ -13,14 +13,14 @@ type UseAutocompleteOptions<TOption extends BaseOption> = {
1313
enabled?: boolean
1414
}
1515

16-
const useAutocomplete = <TOption extends BaseOption = BaseOption>({
16+
export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
1717
id,
1818
value,
1919
getSelectedOption,
2020
getOptions,
2121
onChange,
2222
enabled,
23-
}: UseAutocompleteOptions<TOption>) => {
23+
}: UseFilterMenuOptions<TOption>) => {
2424
const selectedOptionsCacheRef = useRef<Record<string, TOption>>({})
2525
const [query, setQuery] = useState("")
2626
const selectedOptionQuery = useQuery({
@@ -101,23 +101,3 @@ const useAutocomplete = <TOption extends BaseOption = BaseOption>({
101101
isSearching: searchOptionsQuery.isFetching,
102102
}
103103
}
104-
105-
export const useStatusAutocomplete = (
106-
value: string | undefined,
107-
onChange: (option: StatusOption | undefined) => void,
108-
) => {
109-
const statusOptions: StatusOption[] = [
110-
{ value: "active", label: "Active", color: "success" },
111-
{ value: "suspended", label: "Suspended", color: "secondary" },
112-
]
113-
return useAutocomplete({
114-
onChange,
115-
value,
116-
id: "status",
117-
getSelectedOption: async () =>
118-
statusOptions.find((option) => option.value === value) ?? null,
119-
getOptions: async () => statusOptions,
120-
})
121-
}
122-
123-
export type StatusAutocomplete = ReturnType<typeof useStatusAutocomplete>

site/src/pages/UsersPage/filter/options.ts renamed to site/src/components/Filter/options.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,3 @@ export type BaseOption = {
22
label: string
33
value: string
44
}
5-
6-
export type StatusOption = BaseOption & {
7-
color: string
8-
}

0 commit comments

Comments
 (0)