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:
Abhineet Jain
2022-06-28 19:12:15 -04:00
committed by GitHub
parent cb2d1f488a
commit 0d25e1752f
22 changed files with 501 additions and 247 deletions
+19 -9
View File
@@ -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
View File
@@ -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
}
+55 -1
View File
@@ -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("")
})
})
+12
View File
@@ -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 },
}
+12 -105
View File
@@ -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,
},
}))
+4 -4
View File
@@ -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
+29 -10
View File
@@ -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,
},
}
+31 -16
View File
@@ -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)
+17
View File
@@ -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)
})
})
+18
View File
@@ -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 -20
View File
@@ -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)
})
})
})
-12
View File
@@ -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: "",
}
+27 -6
View File
@@ -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)),
},
},
)