mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat(site): add presets back to the filters (#7876)
This commit is contained in:
@ -23,6 +23,13 @@ import { BaseOption } from "./options"
|
||||
import debounce from "just-debounce-it"
|
||||
import MenuList from "@mui/material/MenuList"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import Divider from "@mui/material/Divider"
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"
|
||||
|
||||
export type PresetFilter = {
|
||||
name: string
|
||||
query: string
|
||||
}
|
||||
|
||||
type FilterValues = Record<string, string | undefined>
|
||||
|
||||
@ -127,12 +134,16 @@ export const Filter = ({
|
||||
error,
|
||||
skeleton,
|
||||
options,
|
||||
learnMoreLink,
|
||||
presets,
|
||||
}: {
|
||||
filter: ReturnType<typeof useFilter>
|
||||
skeleton: ReactNode
|
||||
isLoading: boolean
|
||||
learnMoreLink: string
|
||||
error?: unknown
|
||||
options?: ReactNode
|
||||
presets: PresetFilter[]
|
||||
}) => {
|
||||
const shouldDisplayError = hasError(error) && isApiValidationError(error)
|
||||
const hasFilterQuery = filter.query !== ""
|
||||
@ -148,54 +159,71 @@ export const Filter = ({
|
||||
skeleton
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
error={shouldDisplayError}
|
||||
helperText={
|
||||
shouldDisplayError ? getValidationErrorMessage(error) : undefined
|
||||
}
|
||||
size="small"
|
||||
InputProps={{
|
||||
name: "query",
|
||||
placeholder: "Search...",
|
||||
value: searchQuery,
|
||||
onChange: (e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
filter.debounceUpdate(e.target.value)
|
||||
},
|
||||
sx: {
|
||||
borderRadius: "6px",
|
||||
"& input::placeholder": {
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
<Box sx={{ display: "flex", width: "100%" }}>
|
||||
<PresetMenu
|
||||
onSelect={(query) => filter.update(query)}
|
||||
presets={presets}
|
||||
learnMoreLink={learnMoreLink}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
error={shouldDisplayError}
|
||||
helperText={
|
||||
shouldDisplayError
|
||||
? getValidationErrorMessage(error)
|
||||
: undefined
|
||||
}
|
||||
size="small"
|
||||
InputProps={{
|
||||
name: "query",
|
||||
placeholder: "Search...",
|
||||
value: searchQuery,
|
||||
onChange: (e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
filter.debounceUpdate(e.target.value)
|
||||
},
|
||||
},
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchOutlined
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: hasFilterQuery && (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Clear filter">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
filter.update("")
|
||||
sx: {
|
||||
borderRadius: "6px",
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
marginLeft: "-1px",
|
||||
"&:hover": {
|
||||
zIndex: 2,
|
||||
},
|
||||
"& input::placeholder": {
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
},
|
||||
"& .MuiInputAdornment-root": {
|
||||
marginLeft: 0,
|
||||
},
|
||||
},
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchOutlined
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<CloseOutlined sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: hasFilterQuery && (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="Clear filter">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
filter.update("")
|
||||
}}
|
||||
>
|
||||
<CloseOutlined sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{options}
|
||||
</>
|
||||
)}
|
||||
@ -203,6 +231,78 @@ export const Filter = ({
|
||||
)
|
||||
}
|
||||
|
||||
const PresetMenu = ({
|
||||
presets,
|
||||
learnMoreLink,
|
||||
onSelect,
|
||||
}: {
|
||||
presets: PresetFilter[]
|
||||
learnMoreLink: string
|
||||
onSelect: (query: string) => void
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
ref={anchorRef}
|
||||
sx={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
endIcon={<KeyboardArrowDown />}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
<Menu
|
||||
id="filter-menu"
|
||||
anchorEl={anchorRef.current}
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
sx={{ "& .MuiMenu-paper": { py: 1 } }}
|
||||
>
|
||||
{presets.map((presetFilter) => (
|
||||
<MenuItem
|
||||
sx={{ fontSize: 14 }}
|
||||
key={presetFilter.name}
|
||||
onClick={() => {
|
||||
onSelect(presetFilter.query)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{presetFilter.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
|
||||
<MenuItem
|
||||
component="a"
|
||||
href={learnMoreLink}
|
||||
target="_blank"
|
||||
sx={{ fontSize: 13, fontWeight: 500 }}
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<OpenInNewOutlined sx={{ fontSize: "14px !important" }} />
|
||||
View advanced filtering
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterMenu = <TOption extends BaseOption>({
|
||||
id,
|
||||
menu,
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
} from "components/Filter/filter"
|
||||
import { BaseOption } from "components/Filter/options"
|
||||
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
|
||||
import { userFilterQuery } from "utils/filters"
|
||||
|
||||
type StatusOption = BaseOption & {
|
||||
color: string
|
||||
@ -36,6 +37,11 @@ export const useStatusFilterMenu = ({
|
||||
|
||||
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>
|
||||
|
||||
const PRESET_FILTERS = [
|
||||
{ query: userFilterQuery.active, name: "Active users" },
|
||||
{ query: userFilterQuery.all, name: "All users" },
|
||||
]
|
||||
|
||||
export const UsersFilter = ({
|
||||
filter,
|
||||
error,
|
||||
@ -49,6 +55,8 @@ export const UsersFilter = ({
|
||||
}) => {
|
||||
return (
|
||||
<Filter
|
||||
presets={PRESET_FILTERS}
|
||||
learnMoreLink="https://coder.com/docs/v2/latest/admin/users#user-filtering"
|
||||
isLoading={menus.status.isInitializing}
|
||||
filter={filter}
|
||||
error={error}
|
||||
|
@ -4,7 +4,7 @@ import { Helmet } from "react-helmet-async"
|
||||
import { pageTitle } from "utils/page"
|
||||
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
|
||||
import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||
import { useMe, useOrganizationId, usePermissions } from "hooks"
|
||||
import { useOrganizationId, usePermissions } from "hooks"
|
||||
import {
|
||||
useUserFilterMenu,
|
||||
useTemplateFilterMenu,
|
||||
@ -15,7 +15,6 @@ 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
|
||||
@ -23,7 +22,7 @@ const WorkspacesPage: FC = () => {
|
||||
const searchParamsResult = useSearchParams()
|
||||
const pagination = usePagination({ searchParamsResult })
|
||||
const filter = useFilter({
|
||||
initialValue: `owner:${me.username}`,
|
||||
initialValue: `owner:me`,
|
||||
searchParamsResult,
|
||||
onUpdate: () => {
|
||||
pagination.goToPage(1)
|
||||
|
@ -84,7 +84,7 @@ const mockMenu = {
|
||||
|
||||
const defaultFilterProps = {
|
||||
filter: {
|
||||
query: `owner:${MockUser.username}`,
|
||||
query: `owner:me`,
|
||||
update: () => action("update"),
|
||||
debounceUpdate: action("debounce") as any,
|
||||
used: false,
|
||||
|
@ -14,6 +14,20 @@ import {
|
||||
SearchFieldSkeleton,
|
||||
useFilter,
|
||||
} from "components/Filter/filter"
|
||||
import { workspaceFilterQuery } from "utils/filters"
|
||||
|
||||
const PRESET_FILTERS = [
|
||||
{ query: workspaceFilterQuery.me, name: "My workspaces" },
|
||||
{ query: workspaceFilterQuery.all, name: "All workspaces" },
|
||||
{
|
||||
query: workspaceFilterQuery.running,
|
||||
name: "Running workspaces",
|
||||
},
|
||||
{
|
||||
query: workspaceFilterQuery.failed,
|
||||
name: "Failed workspaces",
|
||||
},
|
||||
]
|
||||
|
||||
export const WorkspacesFilter = ({
|
||||
filter,
|
||||
@ -30,9 +44,11 @@ export const WorkspacesFilter = ({
|
||||
}) => {
|
||||
return (
|
||||
<Filter
|
||||
presets={PRESET_FILTERS}
|
||||
isLoading={menus.status.isInitializing}
|
||||
filter={filter}
|
||||
error={error}
|
||||
learnMoreLink="https://coder.com/docs/v2/latest/workspaces#workspace-filtering"
|
||||
options={
|
||||
<>
|
||||
{menus.user && <UserMenu {...menus.user} />}
|
||||
|
@ -29,6 +29,14 @@ export const useUserFilterMenu = ({
|
||||
value,
|
||||
id: "owner",
|
||||
getSelectedOption: async () => {
|
||||
if (value === "me") {
|
||||
return {
|
||||
label: me.username,
|
||||
value: me.username,
|
||||
avatarUrl: me.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
const usersRes = await getUsers({ q: value, limit: 1 })
|
||||
const firstUser = usersRes.users.at(0)
|
||||
if (firstUser && firstUser.username === value) {
|
||||
|
Reference in New Issue
Block a user