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 all commits
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
482 changes: 482 additions & 0 deletions site/src/components/Filter/filter.tsx

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions site/src/components/Filter/menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useMemo, useRef, useState } from "react"
import { BaseOption } from "./options"
import { useQuery } from "@tanstack/react-query"

export type UseFilterMenuOptions<TOption extends BaseOption> = {
id: string
value: string | undefined
// Using null because of react-query
// https://tanstack.com/query/v4/docs/react/guides/migrating-to-react-query-4#undefined-is-an-illegal-cache-value-for-successful-queries
getSelectedOption: () => Promise<TOption | null>
getOptions: (query: string) => Promise<TOption[]>
onChange: (option: TOption | undefined) => void
enabled?: boolean
}

export const useFilterMenu = <TOption extends BaseOption = BaseOption>({
id,
value,
getSelectedOption,
getOptions,
onChange,
enabled,
}: UseFilterMenuOptions<TOption>) => {
const selectedOptionsCacheRef = useRef<Record<string, TOption>>({})
const [query, setQuery] = useState("")
const selectedOptionQuery = useQuery({
queryKey: [id, "autocomplete", "selected", value],
queryFn: () => {
if (!value) {
return null
}

const cachedOption = selectedOptionsCacheRef.current[value]
if (cachedOption) {
return cachedOption
}

return getSelectedOption()
},
enabled,
keepPreviousData: true,
})
const selectedOption = selectedOptionQuery.data
const searchOptionsQuery = useQuery({
queryKey: [id, "autocomplete", "search", query],
queryFn: () => getOptions(query),
enabled,
})
const searchOptions = useMemo(() => {
const isDataLoaded =
searchOptionsQuery.isFetched && selectedOptionQuery.isFetched

if (!isDataLoaded) {
return undefined
}

let options = searchOptionsQuery.data ?? []

if (selectedOption) {
options = options.filter(
(option) => option.value !== selectedOption.value,
)
options = [selectedOption, ...options]
}

options = options.filter(
(option) =>
option.label.toLowerCase().includes(query.toLowerCase()) ||
option.value.toLowerCase().includes(query.toLowerCase()),
)

return options
}, [
selectedOptionQuery.isFetched,
query,
searchOptionsQuery.data,
searchOptionsQuery.isFetched,
selectedOption,
])

const selectOption = (option: TOption) => {
let newSelectedOptionValue: TOption | undefined = option
selectedOptionsCacheRef.current[option.value] = option
setQuery("")

if (option.value === selectedOption?.value) {
newSelectedOptionValue = undefined
}

onChange(newSelectedOptionValue)
}

return {
query,
setQuery,
selectedOption,
selectOption,
searchOptions,
isInitializing: selectedOptionQuery.isInitialLoading,
initialOption: selectedOptionQuery.data,
isSearching: searchOptionsQuery.isFetching,
}
}
4 changes: 4 additions & 0 deletions site/src/components/Filter/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type BaseOption = {
label: string
value: string
}
44 changes: 44 additions & 0 deletions site/src/components/PaginationStatus/PaginationStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Box from "@mui/material/Box"
import Skeleton from "@mui/material/Skeleton"

type BasePaginationStatusProps = {
label: string
isLoading: boolean
showing?: number
total?: number
}

type LoadedPaginationStatusProps = BasePaginationStatusProps & {
isLoading: false
showing: number
total: number
}

export const PaginationStatus = ({
isLoading,
showing,
total,
label,
}: BasePaginationStatusProps | LoadedPaginationStatusProps) => {
return (
<Box
sx={{
fontSize: 13,
mb: 2,
mt: 1,
color: (theme) => theme.palette.text.secondary,
"& strong": { color: (theme) => theme.palette.text.primary },
}}
>
{!isLoading ? (
<>
Showing <strong>{showing}</strong> of <strong>{total}</strong> {label}
</>
) : (
<Box sx={{ height: 24, display: "flex", alignItems: "center" }}>
<Skeleton variant="text" width={160} height={16} />
</Box>
)}
</Box>
)
}
112 changes: 112 additions & 0 deletions site/src/pages/UsersPage/UsersFilter.tsx
Original file line number Diff line number Diff line change
@@ -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,
}}
/>
)
}
47 changes: 41 additions & 6 deletions site/src/pages/UsersPage/UsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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: {
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down
Loading