From fdcc802243d09ae23f6c09c00f08088671b17924 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 2 Jun 2023 15:09:13 +0000 Subject: [PATCH 1/6] Add basic filtering to users --- .../PaginationStatus/PaginationStatus.tsx | 44 +++ site/src/pages/UsersPage/UsersPage.tsx | 47 ++- site/src/pages/UsersPage/UsersPageView.tsx | 32 +- .../pages/UsersPage/filter/autocompletes.ts | 123 +++++++ site/src/pages/UsersPage/filter/filter.tsx | 329 ++++++++++++++++++ site/src/pages/UsersPage/filter/options.ts | 8 + .../WorkspacesPage/WorkspacesPageView.tsx | 29 +- 7 files changed, 574 insertions(+), 38 deletions(-) create mode 100644 site/src/components/PaginationStatus/PaginationStatus.tsx create mode 100644 site/src/pages/UsersPage/filter/autocompletes.ts create mode 100644 site/src/pages/UsersPage/filter/filter.tsx create mode 100644 site/src/pages/UsersPage/filter/options.ts diff --git a/site/src/components/PaginationStatus/PaginationStatus.tsx b/site/src/components/PaginationStatus/PaginationStatus.tsx new file mode 100644 index 0000000000000..0a6d149a383ef --- /dev/null +++ b/site/src/components/PaginationStatus/PaginationStatus.tsx @@ -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 ( + theme.palette.text.secondary, + "& strong": { color: (theme) => theme.palette.text.primary }, + }} + > + {!isLoading ? ( + <> + Showing {showing} of {total} {label} + + ) : ( + + + + )} + + ) +} diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 5a10f43c9292f..adf9dba42597f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 { useFilter } from "./filter/filter" +import { useStatusAutocomplete } from "./filter/autocompletes" +import { useDashboard } from "components/Dashboard/DashboardProvider" 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 statusAutocomplete = useStatusAutocomplete( + useFilterResult.values.status, + (option) => + useFilterResult.update({ + ...useFilterResult.values, + status: option?.value, + }), + ) + return ( <> @@ -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, + autocomplete: { + status: statusAutocomplete, + }, + } + : { + filter: usersState.context.filter, + onFilter: (query) => { + usersSend({ type: "UPDATE_FILTER", query }) + }, + } + } /> void - onFilter: (query: string) => void + filterProps: + | ComponentProps + | ComponentProps paginationRef: PaginationMachineRef isNonInitialPage: boolean actorID: string @@ -48,8 +51,7 @@ export const UsersPageView: FC> = ({ isUpdatingUserRoles, canEditUsers, isLoading, - filter, - onFilter, + filterProps, paginationRef, isNonInitialPage, actorID, @@ -61,11 +63,21 @@ export const UsersPageView: FC> = ({ return ( <> - + ) : ( + + )} + + = { + 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 + getOptions: (query: string) => Promise + onChange: (option: TOption | undefined) => void + enabled?: boolean +} + +const useAutocomplete = ({ + id, + value, + getSelectedOption, + getOptions, + onChange, + enabled, +}: UseAutocompleteOptions) => { + const selectedOptionsCacheRef = useRef>({}) + 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 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 diff --git a/site/src/pages/UsersPage/filter/filter.tsx b/site/src/pages/UsersPage/filter/filter.tsx new file mode 100644 index 0000000000000..7f3816cbf6c7b --- /dev/null +++ b/site/src/pages/UsersPage/filter/filter.tsx @@ -0,0 +1,329 @@ +import { FC, 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 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" +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 { StatusAutocomplete } from "./autocompletes" +import { StatusOption, BaseOption } from "./options" +import debounce from "just-debounce-it" + +export type FilterValues = { + status?: string +} + +export const useFilter = ({ + onUpdate, + searchParamsResult, +}: { + searchParamsResult: ReturnType + onUpdate?: () => void +}) => { + const [searchParams, setSearchParams] = searchParamsResult + const query = searchParams.get("filter") ?? "" + 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 ( + theme.palette.background.paperLight, + borderRadius: "6px", + ...props.sx, + }} + /> + ) +} + +export const Filter = ({ + filter, + autocomplete, + error, +}: { + filter: ReturnType + error?: unknown + autocomplete: { + status: StatusAutocomplete + } +}) => { + 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 ( + + + + + ) + } + + return ( + + { + setSearchQuery(e.target.value) + filter.debounceUpdate(e.target.value) + }, + sx: { + borderRadius: "6px", + "& input::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }, + startAdornment: ( + + theme.palette.text.secondary, + }} + /> + + ), + endAdornment: hasFilterQuery && ( + + + { + filter.update("") + }} + > + + + + + ), + }} + /> + + + + ) +} + +const StatusFilter = ({ + autocomplete, +}: { + autocomplete: StatusAutocomplete +}) => { + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ setIsMenuOpen(true)} + sx={{ width: 200 }} + > + {autocomplete.selectedOption ? ( + + ) : ( + "All statuses" + )} + + + {autocomplete.searchOptions?.map((option) => ( + { + autocomplete.selectOption(option) + handleClose() + }} + > + + + ))} + +
+ ) +} + +const StatusOptionItem = ({ + option, + isSelected, +}: { + option: StatusOption + isSelected?: boolean +}) => { + return ( + } + isSelected={isSelected} + /> + ) +} + +const StatusIndicator: FC<{ option: StatusOption }> = ({ option }) => { + return ( + + (theme.palette[option.color as keyof Palette] as PaletteColor).light, + }} + /> + ) +} + +type OptionItemProps = { + option: BaseOption + left?: ReactNode + isSelected?: boolean +} + +const OptionItem = ({ option, left, isSelected }: OptionItemProps) => { + return ( + + {left} + + {option.label} + + {isSelected && ( + + )} + + ) +} + +const MenuButton = forwardRef((props, ref) => { + return ( +