Skip to content

Commit baa9922

Browse files
feat(site): add new filter to the users page (#7818)
1 parent ee45b3d commit baa9922

15 files changed

+1101
-851
lines changed

site/src/components/Filter/filter.tsx

+482
Large diffs are not rendered by default.

site/src/components/Filter/menu.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useMemo, useRef, useState } from "react"
2+
import { BaseOption } from "./options"
3+
import { useQuery } from "@tanstack/react-query"
4+
5+
export type UseFilterMenuOptions<TOption extends BaseOption> = {
6+
id: string
7+
value: string | undefined
8+
// Using null because of react-query
9+
// https://tanstack.com/query/v4/docs/react/guides/migrating-to-react-query-4#undefined-is-an-illegal-cache-value-for-successful-queries
10+
getSelectedOption: () => Promise<TOption | null>
11+
getOptions: (query: string) => Promise<TOption[]>
12+
onChange: (option: TOption | undefined) => void
13+
enabled?: boolean
14+
}
15+
16+
export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
17+
id,
18+
value,
19+
getSelectedOption,
20+
getOptions,
21+
onChange,
22+
enabled,
23+
}: UseFilterMenuOptions<TOption>) => {
24+
const selectedOptionsCacheRef = useRef<Record<string, TOption>>({})
25+
const [query, setQuery] = useState("")
26+
const selectedOptionQuery = useQuery({
27+
queryKey: [id, "autocomplete", "selected", value],
28+
queryFn: () => {
29+
if (!value) {
30+
return null
31+
}
32+
33+
const cachedOption = selectedOptionsCacheRef.current[value]
34+
if (cachedOption) {
35+
return cachedOption
36+
}
37+
38+
return getSelectedOption()
39+
},
40+
enabled,
41+
keepPreviousData: true,
42+
})
43+
const selectedOption = selectedOptionQuery.data
44+
const searchOptionsQuery = useQuery({
45+
queryKey: [id, "autocomplete", "search", query],
46+
queryFn: () => getOptions(query),
47+
enabled,
48+
})
49+
const searchOptions = useMemo(() => {
50+
const isDataLoaded =
51+
searchOptionsQuery.isFetched && selectedOptionQuery.isFetched
52+
53+
if (!isDataLoaded) {
54+
return undefined
55+
}
56+
57+
let options = searchOptionsQuery.data ?? []
58+
59+
if (selectedOption) {
60+
options = options.filter(
61+
(option) => option.value !== selectedOption.value,
62+
)
63+
options = [selectedOption, ...options]
64+
}
65+
66+
options = options.filter(
67+
(option) =>
68+
option.label.toLowerCase().includes(query.toLowerCase()) ||
69+
option.value.toLowerCase().includes(query.toLowerCase()),
70+
)
71+
72+
return options
73+
}, [
74+
selectedOptionQuery.isFetched,
75+
query,
76+
searchOptionsQuery.data,
77+
searchOptionsQuery.isFetched,
78+
selectedOption,
79+
])
80+
81+
const selectOption = (option: TOption) => {
82+
let newSelectedOptionValue: TOption | undefined = option
83+
selectedOptionsCacheRef.current[option.value] = option
84+
setQuery("")
85+
86+
if (option.value === selectedOption?.value) {
87+
newSelectedOptionValue = undefined
88+
}
89+
90+
onChange(newSelectedOptionValue)
91+
}
92+
93+
return {
94+
query,
95+
setQuery,
96+
selectedOption,
97+
selectOption,
98+
searchOptions,
99+
isInitializing: selectedOptionQuery.isInitialLoading,
100+
initialOption: selectedOptionQuery.data,
101+
isSearching: searchOptionsQuery.isFetching,
102+
}
103+
}

site/src/components/Filter/options.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type BaseOption = {
2+
label: string
3+
value: string
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import Box from "@mui/material/Box"
2+
import Skeleton from "@mui/material/Skeleton"
3+
4+
type BasePaginationStatusProps = {
5+
label: string
6+
isLoading: boolean
7+
showing?: number
8+
total?: number
9+
}
10+
11+
type LoadedPaginationStatusProps = BasePaginationStatusProps & {
12+
isLoading: false
13+
showing: number
14+
total: number
15+
}
16+
17+
export const PaginationStatus = ({
18+
isLoading,
19+
showing,
20+
total,
21+
label,
22+
}: BasePaginationStatusProps | LoadedPaginationStatusProps) => {
23+
return (
24+
<Box
25+
sx={{
26+
fontSize: 13,
27+
mb: 2,
28+
mt: 1,
29+
color: (theme) => theme.palette.text.secondary,
30+
"& strong": { color: (theme) => theme.palette.text.primary },
31+
}}
32+
>
33+
{!isLoading ? (
34+
<>
35+
Showing <strong>{showing}</strong> of <strong>{total}</strong> {label}
36+
</>
37+
) : (
38+
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
39+
<Skeleton variant="text" width={160} height={16} />
40+
</Box>
41+
)}
42+
</Box>
43+
)
44+
}
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { FC } from "react"
2+
import Box from "@mui/material/Box"
3+
import { Palette, PaletteColor } from "@mui/material/styles"
4+
import {
5+
Filter,
6+
FilterMenu,
7+
MenuSkeleton,
8+
OptionItem,
9+
SearchFieldSkeleton,
10+
useFilter,
11+
} from "components/Filter/filter"
12+
import { BaseOption } from "components/Filter/options"
13+
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
14+
15+
type StatusOption = BaseOption & {
16+
color: string
17+
}
18+
19+
export const useStatusFilterMenu = ({
20+
value,
21+
onChange,
22+
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
23+
const statusOptions: StatusOption[] = [
24+
{ value: "active", label: "Active", color: "success" },
25+
{ value: "suspended", label: "Suspended", color: "secondary" },
26+
]
27+
return useFilterMenu({
28+
onChange,
29+
value,
30+
id: "status",
31+
getSelectedOption: async () =>
32+
statusOptions.find((option) => option.value === value) ?? null,
33+
getOptions: async () => statusOptions,
34+
})
35+
}
36+
37+
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>
38+
39+
export const UsersFilter = ({
40+
filter,
41+
error,
42+
menus,
43+
}: {
44+
filter: ReturnType<typeof useFilter>
45+
error?: unknown
46+
menus: {
47+
status: StatusFilterMenu
48+
}
49+
}) => {
50+
return (
51+
<Filter
52+
isLoading={menus.status.isInitializing}
53+
filter={filter}
54+
error={error}
55+
options={<StatusMenu {...menus.status} />}
56+
skeleton={
57+
<>
58+
<SearchFieldSkeleton />
59+
<MenuSkeleton />
60+
</>
61+
}
62+
/>
63+
)
64+
}
65+
66+
const StatusMenu = (menu: StatusFilterMenu) => {
67+
return (
68+
<FilterMenu
69+
id="status-menu"
70+
menu={menu}
71+
label={
72+
menu.selectedOption ? (
73+
<StatusOptionItem option={menu.selectedOption} />
74+
) : (
75+
"All statuses"
76+
)
77+
}
78+
>
79+
{(itemProps) => <StatusOptionItem {...itemProps} />}
80+
</FilterMenu>
81+
)
82+
}
83+
84+
const StatusOptionItem = ({
85+
option,
86+
isSelected,
87+
}: {
88+
option: StatusOption
89+
isSelected?: boolean
90+
}) => {
91+
return (
92+
<OptionItem
93+
option={option}
94+
left={<StatusIndicator option={option} />}
95+
isSelected={isSelected}
96+
/>
97+
)
98+
}
99+
100+
const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => {
101+
return (
102+
<Box
103+
height={8}
104+
width={8}
105+
borderRadius={9999}
106+
sx={{
107+
backgroundColor: (theme) =>
108+
(theme.palette[option.color as keyof Palette] as PaletteColor).light,
109+
}}
110+
/>
111+
)
112+
}

site/src/pages/UsersPage/UsersPage.tsx

+41-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "components/PaginationWidget/utils"
88
import { useMe } from "hooks/useMe"
99
import { usePermissions } from "hooks/usePermissions"
10-
import { FC, ReactNode } from "react"
10+
import { FC, ReactNode, useEffect } from "react"
1111
import { Helmet } from "react-helmet-async"
1212
import { useNavigate } from "react-router"
1313
import { useSearchParams } from "react-router-dom"
@@ -17,6 +17,9 @@ import { ConfirmDialog } from "../../components/Dialogs/ConfirmDialog/ConfirmDia
1717
import { ResetPasswordDialog } from "../../components/Dialogs/ResetPasswordDialog/ResetPasswordDialog"
1818
import { pageTitle } from "../../utils/page"
1919
import { UsersPageView } from "./UsersPageView"
20+
import { useStatusFilterMenu } from "./UsersFilter"
21+
import { useDashboard } from "components/Dashboard/DashboardProvider"
22+
import { useFilter } from "components/Filter/filter"
2023

2124
export const Language = {
2225
suspendDialogTitle: "Suspend user",
@@ -32,7 +35,8 @@ const getSelectedUser = (id: string, users?: User[]) =>
3235

3336
export const UsersPage: FC<{ children?: ReactNode }> = () => {
3437
const navigate = useNavigate()
35-
const [searchParams, setSearchParams] = useSearchParams()
38+
const searchParamsResult = useSearchParams()
39+
const [searchParams, setSearchParams] = searchParamsResult
3640
const filter = searchParams.get("filter") ?? ""
3741
const [usersState, usersSend] = useMachine(usersMachine, {
3842
context: {
@@ -73,6 +77,26 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
7377

7478
const me = useMe()
7579

80+
// New filter
81+
const dashboard = useDashboard()
82+
const useFilterResult = useFilter({
83+
searchParamsResult,
84+
onUpdate: () => {
85+
usersSend({ type: "UPDATE_PAGE", page: "1" })
86+
},
87+
})
88+
useEffect(() => {
89+
usersSend({ type: "UPDATE_FILTER", query: useFilterResult.query })
90+
}, [useFilterResult.query, usersSend])
91+
const statusMenu = useStatusFilterMenu({
92+
value: useFilterResult.values.status,
93+
onChange: (option) =>
94+
useFilterResult.update({
95+
...useFilterResult.values,
96+
status: option?.value,
97+
}),
98+
})
99+
76100
return (
77101
<>
78102
<Helmet>
@@ -123,13 +147,24 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
123147
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
124148
isLoading={isLoading}
125149
canEditUsers={canEditUsers}
126-
filter={usersState.context.filter}
127-
onFilter={(query) => {
128-
usersSend({ type: "UPDATE_FILTER", query })
129-
}}
130150
paginationRef={paginationRef}
131151
isNonInitialPage={nonInitialPage(searchParams)}
132152
actorID={me.id}
153+
filterProps={
154+
dashboard.experiments.includes("workspace_filter")
155+
? {
156+
filter: useFilterResult,
157+
menus: {
158+
status: statusMenu,
159+
},
160+
}
161+
: {
162+
filter: usersState.context.filter,
163+
onFilter: (query) => {
164+
usersSend({ type: "UPDATE_FILTER", query })
165+
},
166+
}
167+
}
133168
/>
134169

135170
<DeleteDialog

0 commit comments

Comments
 (0)