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
feat(site): Add new filter to users
  • Loading branch information
BrunoQuaresma committed Jun 2, 2023
commit e2938fc6f7aa7bfc6537ffa919892809b356a45c
182 changes: 180 additions & 2 deletions site/src/components/Filter/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 Menu, { MenuProps } from "@mui/material/Menu"
import MenuItem from "@mui/material/MenuItem"
import SearchOutlined from "@mui/icons-material/SearchOutlined"
import InputAdornment from "@mui/material/InputAdornment"
Expand All @@ -21,18 +21,22 @@ import {
import { useFilterMenu } from "./menu"
import { BaseOption } from "./options"
import debounce from "just-debounce-it"
import MenuList from "@mui/material/MenuList"
import { Loader } from "components/Loader/Loader"

type FilterValues = Record<string, string | undefined>

export const useFilter = ({
initialValue = "",
onUpdate,
searchParamsResult,
}: {
initialValue?: string
searchParamsResult: ReturnType<typeof useSearchParams>
onUpdate?: () => void
}) => {
const [searchParams, setSearchParams] = searchParamsResult
const query = searchParams.get("filter") ?? ""
const query = searchParams.get("filter") ?? initialValue
const values = parseFilterQuery(query)

const update = (values: string | FilterValues) => {
Expand Down Expand Up @@ -257,6 +261,61 @@ export const FilterMenu = <TOption extends BaseOption>({
)
}

export const FilterSearchMenu = <TOption extends BaseOption>({
id,
menu,
label,
children,
}: {
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)

const handleClose = () => {
setIsMenuOpen(false)
}

return (
<div>
<MenuButton
ref={buttonRef}
onClick={() => setIsMenuOpen(true)}
sx={{ minWidth: 200 }}
>
{label}
</MenuButton>
<SearchMenu
id={id}
anchorEl={buttonRef.current}
open={isMenuOpen}
onClose={handleClose}
options={menu.searchOptions}
query={menu.query}
onQueryChange={menu.setQuery}
renderOption={(option) => (
<MenuItem
key={option.label}
selected={option.value === menu.selectedOption?.value}
onClick={() => {
menu.selectOption(option)
handleClose()
}}
>
{children({
option,
isSelected: option.value === menu.selectedOption?.value,
})}
</MenuItem>
)}
/>
</div>
)
}

type OptionItemProps = {
option: BaseOption
left?: ReactNode
Expand Down Expand Up @@ -299,3 +358,122 @@ const MenuButton = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
/>
)
})

function SearchMenu<TOption extends { label: string; value: string }>({
options,
renderOption,
query,
onQueryChange,
...menuProps
}: Pick<MenuProps, "anchorEl" | "open" | "onClose" | "id"> & {
options?: TOption[]
renderOption: (option: TOption) => ReactNode
query: string
onQueryChange: (query: string) => void
}) {
const menuListRef = useRef<HTMLUListElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)

return (
<Menu
{...menuProps}
onClose={(event, reason) => {
menuProps.onClose && menuProps.onClose(event, reason)
onQueryChange("")
}}
sx={{
"& .MuiPaper-root": {
width: 320,
paddingY: 0,
},
}}
// Disabled this so when we clear the filter and do some sorting in the
// search items it does not look strange. Github removes exit transitions
// on their filters as well.
transitionDuration={{
enter: 250,
exit: 0,
}}
>
<Box
component="li"
sx={{
display: "flex",
alignItems: "center",
paddingLeft: 2,
height: 40,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "ArrowDown" && menuListRef.current) {
const firstItem = menuListRef.current.firstChild as HTMLElement
firstItem.focus()
}
}}
>
<SearchOutlined
sx={{
fontSize: 14,
color: (theme) => theme.palette.text.secondary,
}}
/>
<Box
tabIndex={-1}
component="input"
type="text"
placeholder="Search..."
autoFocus
value={query}
ref={searchInputRef}
onChange={(e) => {
onQueryChange(e.target.value)
}}
sx={{
height: "100%",
border: 0,
background: "none",
width: "100%",
marginLeft: 2,
outline: 0,
"&::placeholder": {
color: (theme) => theme.palette.text.secondary,
},
}}
/>
</Box>

<Box component="li" sx={{ maxHeight: 480, overflowY: "auto" }}>
<MenuList
ref={menuListRef}
onKeyDown={(e) => {
if (e.shiftKey && e.code === "Tab") {
e.preventDefault()
e.stopPropagation()
searchInputRef.current?.focus()
}
}}
>
{options ? (
options.length > 0 ? (
options.map(renderOption)
) : (
<Box
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
textAlign: "center",
py: 1,
}}
>
No results
</Box>
)
) : (
<Loader size={14} />
)}
</MenuList>
</Box>
</Menu>
)
}
51 changes: 28 additions & 23 deletions site/src/pages/WorkspacesPage/WorkspacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ import { Helmet } from "react-helmet-async"
import { pageTitle } from "utils/page"
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
import { WorkspacesPageView } from "./WorkspacesPageView"
import { useFilter } from "./filter/filter"
import { useOrganizationId, usePermissions } from "hooks"
import { useMe, useOrganizationId, usePermissions } from "hooks"
import {
useUsersAutocomplete,
useTemplatesAutocomplete,
useStatusAutocomplete,
} from "./filter/autocompletes"
useUserFilterMenu,
useTemplateFilterMenu,
useStatusFilterMenu,
} from "./filter/menus"
import { useSearchParams } from "react-router-dom"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFilter } from "components/Filter/filter"

const WorkspacesPage: FC = () => {
const me = useMe()
const orgId = useOrganizationId()
// If we use a useSearchParams for each hook, the values will not be in sync.
// So we have to use a single one, centralizing the values, and pass it to
// each hook.
const searchParamsResult = useSearchParams()
const pagination = usePagination({ searchParamsResult })
const filter = useFilter({
initialValue: `owner:${me.username}`,
searchParamsResult,
onUpdate: () => {
pagination.goToPage(1)
Expand All @@ -34,20 +36,23 @@ const WorkspacesPage: FC = () => {
const updateWorkspace = useWorkspaceUpdate(queryKey)
const permissions = usePermissions()
const canFilterByUser = permissions.viewDeploymentValues
const usersAutocomplete = useUsersAutocomplete(
filter.values.owner,
(option) => filter.update({ ...filter.values, owner: option?.value }),
canFilterByUser,
)
const templatesAutocomplete = useTemplatesAutocomplete(
const userMenu = useUserFilterMenu({
value: filter.values.owner,
onChange: (option) =>
filter.update({ ...filter.values, owner: option?.value }),
enabled: canFilterByUser,
})
const templateMenu = useTemplateFilterMenu({
orgId,
filter.values.template,
(option) => filter.update({ ...filter.values, template: option?.value }),
)
const statusAutocomplete = useStatusAutocomplete(
filter.values.status,
(option) => filter.update({ ...filter.values, status: option?.value }),
)
value: filter.values.template,
onChange: (option) =>
filter.update({ ...filter.values, template: option?.value }),
})
const statusMenu = useStatusFilterMenu({
value: filter.values.status,
onChange: (option) =>
filter.update({ ...filter.values, status: option?.value }),
})
const dashboard = useDashboard()

return (
Expand All @@ -65,10 +70,10 @@ const WorkspacesPage: FC = () => {
limit={pagination.limit}
filterProps={{
filter,
autocomplete: {
users: canFilterByUser ? usersAutocomplete : undefined,
templates: templatesAutocomplete,
status: statusAutocomplete,
menus: {
user: canFilterByUser ? userMenu : undefined,
template: templateMenu,
status: statusMenu,
},
}}
onPageChange={pagination.goToPage}
Expand Down
10 changes: 5 additions & 5 deletions site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const MockedAppearance = {
save: () => null,
}

const mockAutocomplete = {
const mockMenu = {
initialOption: undefined,
isInitializing: false,
isSearching: false,
Expand All @@ -93,10 +93,10 @@ const defaultFilterProps = {
status: undefined,
},
},
autocomplete: {
users: mockAutocomplete,
templates: mockAutocomplete,
status: mockAutocomplete,
menus: {
user: mockMenu,
template: mockMenu,
status: mockMenu,
},
} as ComponentProps<typeof WorkspacesPageView>["filterProps"]

Expand Down
6 changes: 3 additions & 3 deletions site/src/pages/WorkspacesPage/WorkspacesPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useLocalStorage } from "hooks"
import difference from "lodash/difference"
import { ImpendingDeletionBanner, Count } from "components/WorkspaceDeletion"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Filter } from "./filter/filter"
import { WorkspacesFilter } from "./filter/filter"
import { hasError, isApiValidationError } from "api/errors"
import { workspaceFilterQuery } from "utils/filters"
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
Expand Down Expand Up @@ -52,7 +52,7 @@ export interface WorkspacesPageViewProps {
useNewFilter?: boolean
page: number
limit: number
filterProps: ComponentProps<typeof Filter>
filterProps: ComponentProps<typeof WorkspacesFilter>
onPageChange: (page: number) => void
onUpdateWorkspace: (workspace: Workspace) => void
}
Expand Down Expand Up @@ -134,7 +134,7 @@ export const WorkspacesPageView: FC<
/>

{useNewFilter ? (
<Filter error={error} {...filterProps} />
<WorkspacesFilter error={error} {...filterProps} />
) : (
<SearchBarWithFilter
filter={filterProps.filter.query}
Expand Down
Loading