mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add filter on Users page (#2653)
This commit adds a new filter feature to the Users page. - adds a filter to the getUsers API call and users state machine. - adds filter UI to Users page view. - addresses error handling in the filter component, users page and machine. - refactors user table code. - refactors common code for workspace filter. - adds and updates unit tests and stories.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import axios from "axios"
|
||||
import { getApiKey, getWorkspacesURL, login, logout } from "./api"
|
||||
import { getApiKey, getURLWithSearchParams, login, logout } from "./api"
|
||||
import * as TypesGen from "./typesGenerated"
|
||||
|
||||
describe("api.ts", () => {
|
||||
@@ -114,16 +114,26 @@ describe("api.ts", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWorkspacesURL", () => {
|
||||
it.each<[TypesGen.WorkspaceFilter | undefined, string]>([
|
||||
[undefined, "/api/v2/workspaces"],
|
||||
describe("getURLWithSearchParams - workspaces", () => {
|
||||
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
|
||||
["/api/v2/workspaces", undefined, "/api/v2/workspaces"],
|
||||
|
||||
[{ q: "" }, "/api/v2/workspaces"],
|
||||
[{ q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"],
|
||||
["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"],
|
||||
["/api/v2/workspaces", { q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"],
|
||||
|
||||
[{ q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"],
|
||||
])(`getWorkspacesURL(%p) returns %p`, (filter, expected) => {
|
||||
expect(getWorkspacesURL(filter)).toBe(expected)
|
||||
["/api/v2/workspaces", { q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"],
|
||||
])(`Workspaces - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => {
|
||||
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getURLWithSearchParams - users", () => {
|
||||
it.each<[string, TypesGen.UsersRequest | undefined, string]>([
|
||||
["/api/v2/users", undefined, "/api/v2/users"],
|
||||
["/api/v2/users", { q: "status:active" }, "/api/v2/users?q=status%3Aactive"],
|
||||
["/api/v2/users", { q: "" }, "/api/v2/users"],
|
||||
])(`Users - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => {
|
||||
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+8
-5
@@ -72,8 +72,9 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getUsers = async (): Promise<TypesGen.User[]> => {
|
||||
const response = await axios.get<TypesGen.User[]>("/api/v2/users?q=status:active,suspended")
|
||||
export const getUsers = async (filter?: TypesGen.UsersRequest): Promise<TypesGen.User[]> => {
|
||||
const url = getURLWithSearchParams("/api/v2/users", filter)
|
||||
const response = await axios.get<TypesGen.User[]>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -144,8 +145,10 @@ export const getWorkspace = async (
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
|
||||
const basePath = "/api/v2/workspaces"
|
||||
export const getURLWithSearchParams = (
|
||||
basePath: string,
|
||||
filter?: TypesGen.WorkspaceFilter | TypesGen.UsersRequest,
|
||||
): string => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (filter?.q && filter.q !== "") {
|
||||
@@ -160,7 +163,7 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
|
||||
export const getWorkspaces = async (
|
||||
filter?: TypesGen.WorkspaceFilter,
|
||||
): Promise<TypesGen.Workspace[]> => {
|
||||
const url = getWorkspacesURL(filter)
|
||||
const url = getURLWithSearchParams("/api/v2/workspaces", filter)
|
||||
const response = await axios.get<TypesGen.Workspace[]>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isApiError, mapApiErrorToFieldErrors } from "./errors"
|
||||
import { getValidationErrorMessage, isApiError, mapApiErrorToFieldErrors } from "./errors"
|
||||
|
||||
describe("isApiError", () => {
|
||||
it("returns true when the object is an API Error", () => {
|
||||
@@ -36,3 +36,57 @@ describe("mapApiErrorToFieldErrors", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getValidationErrorMessage", () => {
|
||||
it("returns multiple validation messages", () => {
|
||||
expect(
|
||||
getValidationErrorMessage({
|
||||
response: {
|
||||
data: {
|
||||
message: "Invalid user search query.",
|
||||
validations: [
|
||||
{
|
||||
field: "status",
|
||||
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
|
||||
},
|
||||
{
|
||||
field: "q",
|
||||
detail: `Query element "role:a:e" can only contain 1 ':'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
}),
|
||||
).toEqual(
|
||||
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
|
||||
)
|
||||
})
|
||||
|
||||
it("non-API error returns empty validation message", () => {
|
||||
expect(
|
||||
getValidationErrorMessage({
|
||||
response: {
|
||||
data: {
|
||||
error: "Invalid user search query.",
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
}),
|
||||
).toEqual("")
|
||||
})
|
||||
|
||||
it("no validations field returns empty validation message", () => {
|
||||
expect(
|
||||
getValidationErrorMessage({
|
||||
response: {
|
||||
data: {
|
||||
message: "Invalid user search query.",
|
||||
detail: `Query element "role:a:e" can only contain 1 ':'`,
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
}),
|
||||
).toEqual("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,3 +71,15 @@ export const getErrorMessage = (
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: defaultMessage
|
||||
|
||||
/**
|
||||
*
|
||||
* @param error
|
||||
* @returns a combined validation error message if the error is an ApiError
|
||||
* and contains validation messages for different form fields.
|
||||
*/
|
||||
export const getValidationErrorMessage = (error: Error | ApiError | unknown): string => {
|
||||
const validationErrors =
|
||||
isApiError(error) && error.response.data.validations ? error.response.data.validations : []
|
||||
return validationErrors.map((error) => error.detail).join("\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { workspaceFilterQuery } from "../../util/workspace"
|
||||
import { userFilterQuery, workspaceFilterQuery } from "../../util/filters"
|
||||
import { SearchBarWithFilter, SearchBarWithFilterProps } from "./SearchBarWithFilter"
|
||||
|
||||
export default {
|
||||
@@ -23,3 +23,26 @@ WithPresetFilters.args = {
|
||||
{ query: "random query", name: "Random query" },
|
||||
],
|
||||
}
|
||||
|
||||
export const WithError = Template.bind({})
|
||||
WithError.args = {
|
||||
filter: "status:inactive",
|
||||
presetFilters: [
|
||||
{ query: userFilterQuery.active, name: "Active users" },
|
||||
{ query: "random query", name: "Random query" },
|
||||
],
|
||||
error: {
|
||||
response: {
|
||||
data: {
|
||||
message: "Invalid user search query.",
|
||||
validations: [
|
||||
{
|
||||
field: "status",
|
||||
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import TextField from "@material-ui/core/TextField"
|
||||
import SearchIcon from "@material-ui/icons/Search"
|
||||
import { FormikErrors, useFormik } from "formik"
|
||||
import { useState } from "react"
|
||||
import { getValidationErrorMessage } from "../../api/errors"
|
||||
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
|
||||
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
|
||||
import { Stack } from "../Stack/Stack"
|
||||
@@ -20,6 +21,7 @@ export interface SearchBarWithFilterProps {
|
||||
filter?: string
|
||||
onFilter: (query: string) => void
|
||||
presetFilters?: PresetFilter[]
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface PresetFilter {
|
||||
@@ -37,6 +39,7 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
|
||||
filter,
|
||||
onFilter,
|
||||
presetFilters,
|
||||
error,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
@@ -68,69 +71,76 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const errorMessage = getValidationErrorMessage(error)
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={0} className={styles.filterContainer}>
|
||||
{presetFilters && presetFilters.length > 0 && (
|
||||
<Button
|
||||
aria-controls="filter-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleClick}
|
||||
className={styles.buttonRoot}
|
||||
>
|
||||
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
|
||||
</Button>
|
||||
)}
|
||||
<Stack spacing={1} className={styles.root}>
|
||||
<Stack direction="row" spacing={0} className={styles.filterContainer}>
|
||||
{presetFilters && presetFilters.length > 0 && (
|
||||
<Button
|
||||
aria-controls="filter-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={handleClick}
|
||||
className={styles.buttonRoot}
|
||||
>
|
||||
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
|
||||
<TextField
|
||||
{...getFieldHelpers("query")}
|
||||
className={styles.textFieldRoot}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
|
||||
<TextField
|
||||
{...getFieldHelpers("query")}
|
||||
className={styles.textFieldRoot}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{presetFilters && presetFilters.length > 0 && (
|
||||
<Menu
|
||||
id="filter-menu"
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
TransitionComponent={Fade}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
{presetFilters.map((presetFilter) => (
|
||||
<MenuItem key={presetFilter.name} onClick={setPresetFilter(presetFilter.query)}>
|
||||
{presetFilter.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
{presetFilters && presetFilters.length > 0 && (
|
||||
<Menu
|
||||
id="filter-menu"
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
TransitionComponent={Fade}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
{presetFilters.map((presetFilter) => (
|
||||
<MenuItem key={presetFilter.name} onClick={setPresetFilter(presetFilter.query)}>
|
||||
{presetFilter.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
</Stack>
|
||||
{errorMessage && <Stack className={styles.errorRoot}>{errorMessage}</Stack>}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
filterContainer: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
filterForm: {
|
||||
width: "100%",
|
||||
@@ -146,4 +156,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
border: "none",
|
||||
},
|
||||
},
|
||||
errorRoot: {
|
||||
color: theme.palette.error.dark,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -28,3 +28,13 @@ Empty.args = {
|
||||
users: [],
|
||||
roles: MockSiteRoles,
|
||||
}
|
||||
|
||||
export const Loading = Template.bind({})
|
||||
Loading.args = {
|
||||
users: [],
|
||||
roles: MockSiteRoles,
|
||||
isLoading: true,
|
||||
}
|
||||
Loading.parameters = {
|
||||
chromatic: { pauseAnimationAtEnd: true },
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Table from "@material-ui/core/Table"
|
||||
import TableBody from "@material-ui/core/TableBody"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
@@ -7,21 +5,10 @@ import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { combineClasses } from "../../util/combineClasses"
|
||||
import { AvatarData } from "../AvatarData/AvatarData"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { RoleSelect } from "../RoleSelect/RoleSelect"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
||||
import { UsersTableBody } from "./UsersTableBody"
|
||||
|
||||
export const Language = {
|
||||
pageTitle: "Users",
|
||||
usersTitle: "All users",
|
||||
emptyMessage: "No users found",
|
||||
usernameLabel: "User",
|
||||
suspendMenuItem: "Suspend",
|
||||
activateMenuItem: "Activate",
|
||||
resetPasswordMenuItem: "Reset password",
|
||||
rolesLabel: "Roles",
|
||||
statusLabel: "Status",
|
||||
}
|
||||
@@ -49,8 +36,6 @@ export const UsersTable: FC<UsersTableProps> = ({
|
||||
canEditUsers,
|
||||
isLoading,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
@@ -63,96 +48,18 @@ export const UsersTable: FC<UsersTableProps> = ({
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
{!isLoading &&
|
||||
users &&
|
||||
users.map((user) => {
|
||||
// When the user has no role we want to show they are a Member
|
||||
const fallbackRole: TypesGen.Role = {
|
||||
name: "member",
|
||||
display_name: "Member",
|
||||
}
|
||||
const userRoles = user.roles.length === 0 ? [fallbackRole] : user.roles
|
||||
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<AvatarData title={user.username} subtitle={user.email} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={combineClasses([
|
||||
styles.status,
|
||||
user.status === "suspended" ? styles.suspended : undefined,
|
||||
])}
|
||||
>
|
||||
{user.status}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditUsers ? (
|
||||
<RoleSelect
|
||||
roles={roles ?? []}
|
||||
selectedRoles={userRoles}
|
||||
loading={isUpdatingUserRoles}
|
||||
onChange={(roles) => {
|
||||
// Remove the fallback role because it is only for the UI
|
||||
roles = roles.filter((role) => role !== fallbackRole.name)
|
||||
onUpdateUserRoles(user, roles)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{userRoles.map((role) => role.display_name).join(", ")}</>
|
||||
)}
|
||||
</TableCell>
|
||||
{canEditUsers && (
|
||||
<TableCell>
|
||||
<TableRowMenu
|
||||
data={user}
|
||||
menuItems={
|
||||
// Return either suspend or activate depending on status
|
||||
(user.status === "active"
|
||||
? [
|
||||
{
|
||||
label: Language.suspendMenuItem,
|
||||
onClick: onSuspendUser,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: Language.activateMenuItem,
|
||||
onClick: onActivateUser,
|
||||
},
|
||||
]
|
||||
).concat({
|
||||
label: Language.resetPasswordMenuItem,
|
||||
onClick: onResetUserPassword,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{users && users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Box p={4}>
|
||||
<EmptyState message={Language.emptyMessage} />
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<UsersTableBody
|
||||
users={users}
|
||||
roles={roles}
|
||||
isLoading={isLoading}
|
||||
canEditUsers={canEditUsers}
|
||||
isUpdatingUserRoles={isUpdatingUserRoles}
|
||||
onActivateUser={onActivateUser}
|
||||
onResetUserPassword={onResetUserPassword}
|
||||
onSuspendUser={onSuspendUser}
|
||||
onUpdateUserRoles={onUpdateUserRoles}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
status: {
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
suspended: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { combineClasses } from "../../util/combineClasses"
|
||||
import { AvatarData } from "../AvatarData/AvatarData"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { RoleSelect } from "../RoleSelect/RoleSelect"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
||||
|
||||
export const Language = {
|
||||
emptyMessage: "No users found",
|
||||
suspendMenuItem: "Suspend",
|
||||
activateMenuItem: "Activate",
|
||||
resetPasswordMenuItem: "Reset password",
|
||||
}
|
||||
|
||||
interface UsersTableBodyProps {
|
||||
users?: TypesGen.User[]
|
||||
roles?: TypesGen.Role[]
|
||||
isUpdatingUserRoles?: boolean
|
||||
canEditUsers?: boolean
|
||||
isLoading?: boolean
|
||||
onSuspendUser: (user: TypesGen.User) => void
|
||||
onActivateUser: (user: TypesGen.User) => void
|
||||
onResetUserPassword: (user: TypesGen.User) => void
|
||||
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
|
||||
}
|
||||
|
||||
export const UsersTableBody: FC<UsersTableBodyProps> = ({
|
||||
users,
|
||||
roles,
|
||||
onSuspendUser,
|
||||
onActivateUser,
|
||||
onResetUserPassword,
|
||||
onUpdateUserRoles,
|
||||
isUpdatingUserRoles,
|
||||
canEditUsers,
|
||||
isLoading,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
if (isLoading) {
|
||||
return <TableLoader />
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<Box p={4}>
|
||||
<EmptyState message={Language.emptyMessage} />
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{users.map((user) => {
|
||||
// When the user has no role we want to show they are a Member
|
||||
const fallbackRole: TypesGen.Role = {
|
||||
name: "member",
|
||||
display_name: "Member",
|
||||
}
|
||||
const userRoles = user.roles.length === 0 ? [fallbackRole] : user.roles
|
||||
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<AvatarData title={user.username} subtitle={user.email} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={combineClasses([
|
||||
styles.status,
|
||||
user.status === "suspended" ? styles.suspended : undefined,
|
||||
])}
|
||||
>
|
||||
{user.status}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditUsers ? (
|
||||
<RoleSelect
|
||||
roles={roles ?? []}
|
||||
selectedRoles={userRoles}
|
||||
loading={isUpdatingUserRoles}
|
||||
onChange={(roles) => {
|
||||
// Remove the fallback role because it is only for the UI
|
||||
roles = roles.filter((role) => role !== fallbackRole.name)
|
||||
onUpdateUserRoles(user, roles)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{userRoles.map((role) => role.display_name).join(", ")}</>
|
||||
)}
|
||||
</TableCell>
|
||||
{canEditUsers && (
|
||||
<TableCell>
|
||||
<TableRowMenu
|
||||
data={user}
|
||||
menuItems={
|
||||
// Return either suspend or activate depending on status
|
||||
(user.status === "active"
|
||||
? [
|
||||
{
|
||||
label: Language.suspendMenuItem,
|
||||
onClick: onSuspendUser,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: Language.activateMenuItem,
|
||||
onClick: onActivateUser,
|
||||
},
|
||||
]
|
||||
).concat({
|
||||
label: Language.resetPasswordMenuItem,
|
||||
onClick: onResetUserPassword,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
status: {
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
suspended: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
@@ -5,7 +5,7 @@ import { Role } from "../../api/typesGenerated"
|
||||
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
|
||||
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
|
||||
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
|
||||
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
|
||||
import { Language as UsersTableBodyLanguage } from "../../components/UsersTable/UsersTableBody"
|
||||
import {
|
||||
MockAuditorRole,
|
||||
MockUser,
|
||||
@@ -31,7 +31,7 @@ const suspendUser = async (setupActionSpies: () => void) => {
|
||||
const moreButton = within(firstUserRow).getByLabelText("more")
|
||||
fireEvent.click(moreButton)
|
||||
const menu = screen.getByRole("menu")
|
||||
const suspendButton = within(menu).getByText(UsersTableLanguage.suspendMenuItem)
|
||||
const suspendButton = within(menu).getByText(UsersTableBodyLanguage.suspendMenuItem)
|
||||
fireEvent.click(suspendButton)
|
||||
|
||||
// Check if the confirm message is displayed
|
||||
@@ -60,7 +60,7 @@ const activateUser = async (setupActionSpies: () => void) => {
|
||||
const moreButton = within(firstUserRow).getByLabelText("more")
|
||||
fireEvent.click(moreButton)
|
||||
const menu = screen.getByRole("menu")
|
||||
const activateButton = within(menu).getByText(UsersTableLanguage.activateMenuItem)
|
||||
const activateButton = within(menu).getByText(UsersTableBodyLanguage.activateMenuItem)
|
||||
fireEvent.click(activateButton)
|
||||
|
||||
// Check if the confirm message is displayed
|
||||
@@ -89,7 +89,7 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
|
||||
const moreButton = within(firstUserRow).getByLabelText("more")
|
||||
fireEvent.click(moreButton)
|
||||
const menu = screen.getByRole("menu")
|
||||
const resetPasswordButton = within(menu).getByText(UsersTableLanguage.resetPasswordMenuItem)
|
||||
const resetPasswordButton = within(menu).getByText(UsersTableBodyLanguage.resetPasswordMenuItem)
|
||||
fireEvent.click(resetPasswordButton)
|
||||
|
||||
// Check if the confirm message is displayed
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useActor, useSelector } from "@xstate/react"
|
||||
import { useActor } from "@xstate/react"
|
||||
import React, { useContext, useEffect } from "react"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
|
||||
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
|
||||
import { userFilterQuery } from "../../util/filters"
|
||||
import { pageTitle } from "../../util/page"
|
||||
import { selectPermissions } from "../../xServices/auth/authSelectors"
|
||||
import { XServiceContext } from "../../xServices/StateContext"
|
||||
import { UsersPageView } from "./UsersPageView"
|
||||
|
||||
@@ -21,7 +22,6 @@ export const Language = {
|
||||
export const UsersPage: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [usersState, usersSend] = useActor(xServices.usersXService)
|
||||
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
|
||||
const {
|
||||
users,
|
||||
getUsersError,
|
||||
@@ -31,23 +31,37 @@ export const UsersPage: React.FC = () => {
|
||||
newUserPassword,
|
||||
} = usersState.context
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
|
||||
const userToBeActivated = users?.find((u) => u.id === userIdToActivate)
|
||||
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
|
||||
const permissions = useSelector(xServices.authXService, selectPermissions)
|
||||
|
||||
const [authState, _] = useActor(xServices.authXService)
|
||||
const { permissions } = authState.context
|
||||
const canEditUsers = permissions && permissions.updateUsers
|
||||
const canCreateUser = permissions && permissions.createUser
|
||||
|
||||
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
|
||||
const { roles } = rolesState.context
|
||||
|
||||
// Is loading if
|
||||
// - permissions are not loaded or
|
||||
// - users are not loaded or
|
||||
// - the user can edit the users but the roles are not loaded yet
|
||||
const isLoading = !permissions || !users || (canEditUsers && !roles)
|
||||
// - permissions are loading or
|
||||
// - users are loading or
|
||||
// - the user can edit the users but the roles are loading
|
||||
const isLoading =
|
||||
authState.matches("gettingPermissions") ||
|
||||
usersState.matches("gettingUsers") ||
|
||||
(canEditUsers && rolesState.matches("gettingRoles"))
|
||||
|
||||
// Fetch users on component mount
|
||||
useEffect(() => {
|
||||
usersSend("GET_USERS")
|
||||
}, [usersSend])
|
||||
const filter = searchParams.get("filter")
|
||||
const query = filter ?? userFilterQuery.active
|
||||
usersSend({
|
||||
type: "GET_USERS",
|
||||
query,
|
||||
})
|
||||
}, [searchParams, usersSend])
|
||||
|
||||
// Fetch roles on component mount
|
||||
useEffect(() => {
|
||||
@@ -91,6 +105,11 @@ export const UsersPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
canEditUsers={canEditUsers}
|
||||
canCreateUser={canCreateUser}
|
||||
filter={usersState.context.filter}
|
||||
onFilter={(query) => {
|
||||
searchParams.set("filter", query)
|
||||
setSearchParams(searchParams)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -22,3 +22,23 @@ Member.args = { ...Admin.args, canCreateUser: false, canEditUsers: false }
|
||||
|
||||
export const Empty = Template.bind({})
|
||||
Empty.args = { ...Admin.args, users: [] }
|
||||
|
||||
export const Error = Template.bind({})
|
||||
Error.args = {
|
||||
...Admin.args,
|
||||
users: undefined,
|
||||
error: {
|
||||
response: {
|
||||
data: {
|
||||
message: "Invalid user search query.",
|
||||
validations: [
|
||||
{
|
||||
field: "status",
|
||||
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ import Button from "@material-ui/core/Button"
|
||||
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
|
||||
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
|
||||
import { UsersTable } from "../../components/UsersTable/UsersTable"
|
||||
import { userFilterQuery } from "../../util/filters"
|
||||
|
||||
export const Language = {
|
||||
pageTitle: "Users",
|
||||
createButton: "New user",
|
||||
activeUsersFilterName: "Active users",
|
||||
allUsersFilterName: "All users",
|
||||
}
|
||||
|
||||
export interface UsersPageViewProps {
|
||||
users?: TypesGen.User[]
|
||||
roles?: TypesGen.Role[]
|
||||
filter?: string
|
||||
error?: unknown
|
||||
isUpdatingUserRoles?: boolean
|
||||
canEditUsers?: boolean
|
||||
@@ -25,6 +29,7 @@ export interface UsersPageViewProps {
|
||||
onActivateUser: (user: TypesGen.User) => void
|
||||
onResetUserPassword: (user: TypesGen.User) => void
|
||||
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
|
||||
onFilter: (query: string) => void
|
||||
}
|
||||
|
||||
export const UsersPageView: FC<UsersPageViewProps> = ({
|
||||
@@ -40,7 +45,14 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
|
||||
canEditUsers,
|
||||
canCreateUser,
|
||||
isLoading,
|
||||
filter,
|
||||
onFilter,
|
||||
}) => {
|
||||
const presetFilters = [
|
||||
{ query: userFilterQuery.active, name: Language.activeUsersFilterName },
|
||||
{ query: userFilterQuery.all, name: Language.allUsersFilterName },
|
||||
]
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader
|
||||
@@ -55,21 +67,24 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
|
||||
<PageHeaderTitle>Users</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
|
||||
{error ? (
|
||||
<ErrorSummary error={error} />
|
||||
) : (
|
||||
<UsersTable
|
||||
users={users}
|
||||
roles={roles}
|
||||
onSuspendUser={onSuspendUser}
|
||||
onActivateUser={onActivateUser}
|
||||
onResetUserPassword={onResetUserPassword}
|
||||
onUpdateUserRoles={onUpdateUserRoles}
|
||||
isUpdatingUserRoles={isUpdatingUserRoles}
|
||||
canEditUsers={canEditUsers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<SearchBarWithFilter
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
presetFilters={presetFilters}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<UsersTable
|
||||
users={users}
|
||||
roles={roles}
|
||||
onSuspendUser={onSuspendUser}
|
||||
onActivateUser={onActivateUser}
|
||||
onResetUserPassword={onResetUserPassword}
|
||||
onUpdateUserRoles={onUpdateUserRoles}
|
||||
isUpdatingUserRoles={isUpdatingUserRoles}
|
||||
canEditUsers={canEditUsers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMachine } from "@xstate/react"
|
||||
import { FC, useEffect } from "react"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { workspaceFilterQuery } from "../../util/filters"
|
||||
import { pageTitle } from "../../util/page"
|
||||
import { workspaceFilterQuery } from "../../util/workspace"
|
||||
import { workspacesMachine } from "../../xServices/workspaces/workspacesXService"
|
||||
import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||
|
||||
@@ -14,7 +14,7 @@ const WorkspacesPage: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get("filter")
|
||||
const query = filter !== null ? filter : workspaceFilterQuery.me
|
||||
const query = filter ?? workspaceFilterQuery.me
|
||||
|
||||
send({
|
||||
type: "GET_WORKSPACES",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { spawn } from "xstate"
|
||||
import { ProvisionerJobStatus, WorkspaceTransition } from "../../api/typesGenerated"
|
||||
import { MockWorkspace } from "../../testHelpers/entities"
|
||||
import { workspaceFilterQuery } from "../../util/workspace"
|
||||
import { workspaceFilterQuery } from "../../util/filters"
|
||||
import {
|
||||
workspaceItemMachine,
|
||||
WorkspaceItemMachineRef,
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
|
||||
import { getDisplayStatus, workspaceFilterQuery } from "../../util/workspace"
|
||||
import { workspaceFilterQuery } from "../../util/filters"
|
||||
import { getDisplayStatus } from "../../util/workspace"
|
||||
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
import { queryToFilter } from "./filters"
|
||||
|
||||
describe("queryToFilter", () => {
|
||||
it.each<[string | undefined, TypesGen.WorkspaceFilter | TypesGen.UsersRequest]>([
|
||||
[undefined, {}],
|
||||
["", { q: "" }],
|
||||
["asdkfvjn", { q: "asdkfvjn" }],
|
||||
["owner:me", { q: "owner:me" }],
|
||||
["owner:me owner:me2", { q: "owner:me owner:me2" }],
|
||||
["me/dev", { q: "me/dev" }],
|
||||
["me/", { q: "me/" }],
|
||||
[" key:val owner:me ", { q: "key:val owner:me" }],
|
||||
])(`query=%p, filter=%p`, (query, filter) => {
|
||||
expect(queryToFilter(query)).toEqual(filter)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
|
||||
export const queryToFilter = (query?: string): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
|
||||
const preparedQuery = query?.trim().replace(/ +/g, " ")
|
||||
return {
|
||||
q: preparedQuery,
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
}
|
||||
|
||||
export const userFilterQuery = {
|
||||
active: "status:active",
|
||||
all: "",
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import dayjs from "dayjs"
|
||||
import * as TypesGen from "../api/typesGenerated"
|
||||
import * as Mocks from "../testHelpers/entities"
|
||||
import {
|
||||
defaultWorkspaceExtension,
|
||||
isWorkspaceDeleted,
|
||||
isWorkspaceOn,
|
||||
workspaceQueryToFilter,
|
||||
} from "./workspace"
|
||||
import { defaultWorkspaceExtension, isWorkspaceDeleted, isWorkspaceOn } from "./workspace"
|
||||
|
||||
describe("util > workspace", () => {
|
||||
describe("isWorkspaceOn", () => {
|
||||
@@ -106,18 +101,4 @@ describe("util > workspace", () => {
|
||||
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
|
||||
})
|
||||
})
|
||||
describe("workspaceQueryToFilter", () => {
|
||||
it.each<[string | undefined, TypesGen.WorkspaceFilter]>([
|
||||
[undefined, {}],
|
||||
["", { q: "" }],
|
||||
["asdkfvjn", { q: "asdkfvjn" }],
|
||||
["owner:me", { q: "owner:me" }],
|
||||
["owner:me owner:me2", { q: "owner:me owner:me2" }],
|
||||
["me/dev", { q: "me/dev" }],
|
||||
["me/", { q: "me/" }],
|
||||
[" key:val owner:me ", { q: "key:val owner:me" }],
|
||||
])(`query=%p, filter=%p`, (query, filter) => {
|
||||
expect(workspaceQueryToFilter(query)).toEqual(filter)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -296,15 +296,3 @@ export const defaultWorkspaceExtension = (
|
||||
deadline: fourHoursFromNow.format(),
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceQueryToFilter = (query?: string): TypesGen.WorkspaceFilter => {
|
||||
const preparedQuery = query?.trim().replace(/ +/g, " ")
|
||||
return {
|
||||
q: preparedQuery,
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from "../../api/errors"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
|
||||
import { queryToFilter } from "../../util/filters"
|
||||
import { generateRandomString } from "../../util/random"
|
||||
|
||||
export const Language = {
|
||||
getUsersError: "Error getting users.",
|
||||
createUserSuccess: "Successfully created user.",
|
||||
createUserError: "Error on creating the user.",
|
||||
suspendUserSuccess: "Successfully suspended the user.",
|
||||
@@ -28,6 +30,7 @@ export const Language = {
|
||||
export interface UsersContext {
|
||||
// Get users
|
||||
users?: TypesGen.User[]
|
||||
filter?: string
|
||||
getUsersError?: Error | unknown
|
||||
createUserErrorMessage?: string
|
||||
createUserFormErrors?: FieldErrors
|
||||
@@ -47,7 +50,7 @@ export interface UsersContext {
|
||||
}
|
||||
|
||||
export type UsersEvent =
|
||||
| { type: "GET_USERS" }
|
||||
| { type: "GET_USERS"; query: string }
|
||||
| { type: "CREATE"; user: TypesGen.CreateUserRequest }
|
||||
| { type: "CANCEL_CREATE_USER" }
|
||||
// Suspend events
|
||||
@@ -97,7 +100,10 @@ export const usersMachine = createMachine(
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GET_USERS: "gettingUsers",
|
||||
GET_USERS: {
|
||||
actions: "assignFilter",
|
||||
target: "gettingUsers",
|
||||
},
|
||||
CREATE: "creatingUser",
|
||||
CANCEL_CREATE_USER: { actions: ["clearCreateUserError"] },
|
||||
SUSPEND_USER: {
|
||||
@@ -119,18 +125,19 @@ export const usersMachine = createMachine(
|
||||
},
|
||||
},
|
||||
gettingUsers: {
|
||||
entry: "clearGetUsersError",
|
||||
invoke: {
|
||||
src: "getUsers",
|
||||
id: "getUsers",
|
||||
onDone: [
|
||||
{
|
||||
target: "#usersState.idle",
|
||||
actions: ["assignUsers", "clearGetUsersError"],
|
||||
actions: "assignUsers",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignGetUsersError",
|
||||
actions: ["clearUsers", "assignGetUsersError", "displayGetUsersErrorMessage"],
|
||||
target: "#usersState.error",
|
||||
},
|
||||
],
|
||||
@@ -242,7 +249,10 @@ export const usersMachine = createMachine(
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
GET_USERS: "gettingUsers",
|
||||
GET_USERS: {
|
||||
actions: "assignFilter",
|
||||
target: "gettingUsers",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -252,7 +262,7 @@ export const usersMachine = createMachine(
|
||||
// Passing API.getUsers directly does not invoke the function properly
|
||||
// when it is mocked. This happen in the UsersPage tests inside of the
|
||||
// "shows a success message and refresh the page" test case.
|
||||
getUsers: () => API.getUsers(),
|
||||
getUsers: (context) => API.getUsers(queryToFilter(context.filter)),
|
||||
createUser: (_, event) => API.createUser(event.user),
|
||||
suspendUser: (context) => {
|
||||
if (!context.userIdToSuspend) {
|
||||
@@ -297,6 +307,9 @@ export const usersMachine = createMachine(
|
||||
assignUsers: assign({
|
||||
users: (_, event) => event.data,
|
||||
}),
|
||||
assignFilter: assign({
|
||||
filter: (_, event) => event.query,
|
||||
}),
|
||||
assignGetUsersError: assign({
|
||||
getUsersError: (_, event) => event.data,
|
||||
}),
|
||||
@@ -336,6 +349,10 @@ export const usersMachine = createMachine(
|
||||
assignUpdateRolesError: assign({
|
||||
updateUserRolesError: (_, event) => event.data,
|
||||
}),
|
||||
clearUsers: assign((context: UsersContext) => ({
|
||||
...context,
|
||||
users: undefined,
|
||||
})),
|
||||
clearCreateUserError: assign((context: UsersContext) => ({
|
||||
...context,
|
||||
createUserErrorMessage: undefined,
|
||||
@@ -353,6 +370,10 @@ export const usersMachine = createMachine(
|
||||
clearUpdateUserRolesError: assign({
|
||||
updateUserRolesError: (_) => undefined,
|
||||
}),
|
||||
displayGetUsersErrorMessage: (context) => {
|
||||
const message = getErrorMessage(context.getUsersError, Language.getUsersError)
|
||||
displayError(message)
|
||||
},
|
||||
displayCreateUserSuccess: () => {
|
||||
displaySuccess(Language.createUserSuccess)
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as API from "../../api/api"
|
||||
import { getErrorMessage } from "../../api/errors"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { displayError, displayMsg, displaySuccess } from "../../components/GlobalSnackbar/utils"
|
||||
import { workspaceQueryToFilter } from "../../util/workspace"
|
||||
import { queryToFilter } from "../../util/filters"
|
||||
|
||||
/**
|
||||
* Workspace item machine
|
||||
@@ -318,7 +318,7 @@ export const workspacesMachine = createMachine(
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getWorkspaces: (context) => API.getWorkspaces(workspaceQueryToFilter(context.filter)),
|
||||
getWorkspaces: (context) => API.getWorkspaces(queryToFilter(context.filter)),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user