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 debounce from "just-debounce-it"
|
||||||
import MenuList from "@mui/material/MenuList"
|
import MenuList from "@mui/material/MenuList"
|
||||||
import { Loader } from "components/Loader/Loader"
|
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>
|
type FilterValues = Record<string, string | undefined>
|
||||||
|
|
||||||
@ -127,12 +134,16 @@ export const Filter = ({
|
|||||||
error,
|
error,
|
||||||
skeleton,
|
skeleton,
|
||||||
options,
|
options,
|
||||||
|
learnMoreLink,
|
||||||
|
presets,
|
||||||
}: {
|
}: {
|
||||||
filter: ReturnType<typeof useFilter>
|
filter: ReturnType<typeof useFilter>
|
||||||
skeleton: ReactNode
|
skeleton: ReactNode
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
learnMoreLink: string
|
||||||
error?: unknown
|
error?: unknown
|
||||||
options?: ReactNode
|
options?: ReactNode
|
||||||
|
presets: PresetFilter[]
|
||||||
}) => {
|
}) => {
|
||||||
const shouldDisplayError = hasError(error) && isApiValidationError(error)
|
const shouldDisplayError = hasError(error) && isApiValidationError(error)
|
||||||
const hasFilterQuery = filter.query !== ""
|
const hasFilterQuery = filter.query !== ""
|
||||||
@ -148,54 +159,71 @@ export const Filter = ({
|
|||||||
skeleton
|
skeleton
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<Box sx={{ display: "flex", width: "100%" }}>
|
||||||
fullWidth
|
<PresetMenu
|
||||||
error={shouldDisplayError}
|
onSelect={(query) => filter.update(query)}
|
||||||
helperText={
|
presets={presets}
|
||||||
shouldDisplayError ? getValidationErrorMessage(error) : undefined
|
learnMoreLink={learnMoreLink}
|
||||||
}
|
/>
|
||||||
size="small"
|
<TextField
|
||||||
InputProps={{
|
fullWidth
|
||||||
name: "query",
|
error={shouldDisplayError}
|
||||||
placeholder: "Search...",
|
helperText={
|
||||||
value: searchQuery,
|
shouldDisplayError
|
||||||
onChange: (e) => {
|
? getValidationErrorMessage(error)
|
||||||
setSearchQuery(e.target.value)
|
: undefined
|
||||||
filter.debounceUpdate(e.target.value)
|
}
|
||||||
},
|
size="small"
|
||||||
sx: {
|
InputProps={{
|
||||||
borderRadius: "6px",
|
name: "query",
|
||||||
"& input::placeholder": {
|
placeholder: "Search...",
|
||||||
color: (theme) => theme.palette.text.secondary,
|
value: searchQuery,
|
||||||
|
onChange: (e) => {
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
filter.debounceUpdate(e.target.value)
|
||||||
},
|
},
|
||||||
},
|
sx: {
|
||||||
startAdornment: (
|
borderRadius: "6px",
|
||||||
<InputAdornment position="start">
|
borderTopLeftRadius: 0,
|
||||||
<SearchOutlined
|
borderBottomLeftRadius: 0,
|
||||||
sx={{
|
marginLeft: "-1px",
|
||||||
fontSize: 14,
|
"&:hover": {
|
||||||
color: (theme) => theme.palette.text.secondary,
|
zIndex: 2,
|
||||||
}}
|
},
|
||||||
/>
|
"& input::placeholder": {
|
||||||
</InputAdornment>
|
color: (theme) => theme.palette.text.secondary,
|
||||||
),
|
},
|
||||||
endAdornment: hasFilterQuery && (
|
"& .MuiInputAdornment-root": {
|
||||||
<InputAdornment position="end">
|
marginLeft: 0,
|
||||||
<Tooltip title="Clear filter">
|
},
|
||||||
<IconButton
|
},
|
||||||
size="small"
|
startAdornment: (
|
||||||
onClick={() => {
|
<InputAdornment position="start">
|
||||||
filter.update("")
|
<SearchOutlined
|
||||||
|
sx={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: (theme) => theme.palette.text.secondary,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<CloseOutlined sx={{ fontSize: 14 }} />
|
</InputAdornment>
|
||||||
</IconButton>
|
),
|
||||||
</Tooltip>
|
endAdornment: hasFilterQuery && (
|
||||||
</InputAdornment>
|
<InputAdornment position="end">
|
||||||
),
|
<Tooltip title="Clear filter">
|
||||||
}}
|
<IconButton
|
||||||
/>
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
filter.update("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
{options}
|
{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>({
|
export const FilterMenu = <TOption extends BaseOption>({
|
||||||
id,
|
id,
|
||||||
menu,
|
menu,
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
} from "components/Filter/filter"
|
} from "components/Filter/filter"
|
||||||
import { BaseOption } from "components/Filter/options"
|
import { BaseOption } from "components/Filter/options"
|
||||||
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
|
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
|
||||||
|
import { userFilterQuery } from "utils/filters"
|
||||||
|
|
||||||
type StatusOption = BaseOption & {
|
type StatusOption = BaseOption & {
|
||||||
color: string
|
color: string
|
||||||
@ -36,6 +37,11 @@ export const useStatusFilterMenu = ({
|
|||||||
|
|
||||||
export type StatusFilterMenu = ReturnType<typeof 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 = ({
|
export const UsersFilter = ({
|
||||||
filter,
|
filter,
|
||||||
error,
|
error,
|
||||||
@ -49,6 +55,8 @@ export const UsersFilter = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Filter
|
<Filter
|
||||||
|
presets={PRESET_FILTERS}
|
||||||
|
learnMoreLink="https://coder.com/docs/v2/latest/admin/users#user-filtering"
|
||||||
isLoading={menus.status.isInitializing}
|
isLoading={menus.status.isInitializing}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
error={error}
|
error={error}
|
||||||
|
@ -4,7 +4,7 @@ import { Helmet } from "react-helmet-async"
|
|||||||
import { pageTitle } from "utils/page"
|
import { pageTitle } from "utils/page"
|
||||||
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
|
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
|
||||||
import { WorkspacesPageView } from "./WorkspacesPageView"
|
import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||||
import { useMe, useOrganizationId, usePermissions } from "hooks"
|
import { useOrganizationId, usePermissions } from "hooks"
|
||||||
import {
|
import {
|
||||||
useUserFilterMenu,
|
useUserFilterMenu,
|
||||||
useTemplateFilterMenu,
|
useTemplateFilterMenu,
|
||||||
@ -15,7 +15,6 @@ import { useDashboard } from "components/Dashboard/DashboardProvider"
|
|||||||
import { useFilter } from "components/Filter/filter"
|
import { useFilter } from "components/Filter/filter"
|
||||||
|
|
||||||
const WorkspacesPage: FC = () => {
|
const WorkspacesPage: FC = () => {
|
||||||
const me = useMe()
|
|
||||||
const orgId = useOrganizationId()
|
const orgId = useOrganizationId()
|
||||||
// If we use a useSearchParams for each hook, the values will not be in sync.
|
// 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
|
// 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 searchParamsResult = useSearchParams()
|
||||||
const pagination = usePagination({ searchParamsResult })
|
const pagination = usePagination({ searchParamsResult })
|
||||||
const filter = useFilter({
|
const filter = useFilter({
|
||||||
initialValue: `owner:${me.username}`,
|
initialValue: `owner:me`,
|
||||||
searchParamsResult,
|
searchParamsResult,
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
pagination.goToPage(1)
|
pagination.goToPage(1)
|
||||||
|
@ -84,7 +84,7 @@ const mockMenu = {
|
|||||||
|
|
||||||
const defaultFilterProps = {
|
const defaultFilterProps = {
|
||||||
filter: {
|
filter: {
|
||||||
query: `owner:${MockUser.username}`,
|
query: `owner:me`,
|
||||||
update: () => action("update"),
|
update: () => action("update"),
|
||||||
debounceUpdate: action("debounce") as any,
|
debounceUpdate: action("debounce") as any,
|
||||||
used: false,
|
used: false,
|
||||||
|
@ -14,6 +14,20 @@ import {
|
|||||||
SearchFieldSkeleton,
|
SearchFieldSkeleton,
|
||||||
useFilter,
|
useFilter,
|
||||||
} from "components/Filter/filter"
|
} 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 = ({
|
export const WorkspacesFilter = ({
|
||||||
filter,
|
filter,
|
||||||
@ -30,9 +44,11 @@ export const WorkspacesFilter = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Filter
|
<Filter
|
||||||
|
presets={PRESET_FILTERS}
|
||||||
isLoading={menus.status.isInitializing}
|
isLoading={menus.status.isInitializing}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
error={error}
|
error={error}
|
||||||
|
learnMoreLink="https://coder.com/docs/v2/latest/workspaces#workspace-filtering"
|
||||||
options={
|
options={
|
||||||
<>
|
<>
|
||||||
{menus.user && <UserMenu {...menus.user} />}
|
{menus.user && <UserMenu {...menus.user} />}
|
||||||
|
@ -29,6 +29,14 @@ export const useUserFilterMenu = ({
|
|||||||
value,
|
value,
|
||||||
id: "owner",
|
id: "owner",
|
||||||
getSelectedOption: async () => {
|
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 usersRes = await getUsers({ q: value, limit: 1 })
|
||||||
const firstUser = usersRes.users.at(0)
|
const firstUser = usersRes.users.at(0)
|
||||||
if (firstUser && firstUser.username === value) {
|
if (firstUser && firstUser.username === value) {
|
||||||
|
Reference in New Issue
Block a user