diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index f7455a9910b32..83c02f0ccf23f 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -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) }) }) }) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 90af2f4d5aba3..5938666d1c997 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -72,8 +72,9 @@ export const getApiKey = async (): Promise => { return response.data } -export const getUsers = async (): Promise => { - const response = await axios.get("/api/v2/users?q=status:active,suspended") +export const getUsers = async (filter?: TypesGen.UsersRequest): Promise => { + const url = getURLWithSearchParams("/api/v2/users", filter) + const response = await axios.get(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 => { - const url = getWorkspacesURL(filter) + const url = getURLWithSearchParams("/api/v2/workspaces", filter) const response = await axios.get(url) return response.data } diff --git a/site/src/api/errors.test.ts b/site/src/api/errors.test.ts index 6402ed5b7f677..63b2f33527000 100644 --- a/site/src/api/errors.test.ts +++ b/site/src/api/errors.test.ts @@ -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("") + }) +}) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 81311b7f7b276..bc981bbafb256 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -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") +} diff --git a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx index 2df5a0d485c82..4d7ff6833e47a 100644 --- a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx +++ b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx @@ -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, + }, +} diff --git a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx index b44218dd8454c..816848249f331 100644 --- a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx +++ b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx @@ -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 = ({ filter, onFilter, presetFilters, + error, }) => { const styles = useStyles() @@ -68,69 +71,76 @@ export const SearchBarWithFilter: React.FC = ({ handleClose() } + const errorMessage = getValidationErrorMessage(error) + return ( - - {presetFilters && presetFilters.length > 0 && ( - - )} - -
- - - - ), - }} - /> - - - {presetFilters && presetFilters.length > 0 && ( - - {presetFilters.map((presetFilter) => ( - - {presetFilter.name} - - ))} - - )} + + + {presetFilters && presetFilters.length > 0 && ( + + )} + +
+ + + + ), + }} + /> + + + {presetFilters && presetFilters.length > 0 && ( + + {presetFilters.map((presetFilter) => ( + + {presetFilter.name} + + ))} + + )} +
+ {errorMessage && {errorMessage}}
) } 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, + }, })) diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index 0719d02a8d8d5..7219993daf2d6 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -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 }, +} diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 14ab43418d26a..ff3b65b168ec1 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -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 = ({ canEditUsers, isLoading, }) => { - const styles = useStyles() - return ( @@ -63,96 +48,18 @@ export const UsersTable: FC = ({ - {isLoading && } - {!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 ( - - - - - - {user.status} - - - {canEditUsers ? ( - { - // 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(", ")} - )} - - {canEditUsers && ( - - - - )} - - ) - })} - - {users && users.length === 0 && ( - - - - - - - - )} +
) } - -const useStyles = makeStyles((theme) => ({ - status: { - textTransform: "capitalize", - }, - suspended: { - color: theme.palette.text.secondary, - }, -})) diff --git a/site/src/components/UsersTable/UsersTableBody.tsx b/site/src/components/UsersTable/UsersTableBody.tsx new file mode 100644 index 0000000000000..37ac9d8a7286c --- /dev/null +++ b/site/src/components/UsersTable/UsersTableBody.tsx @@ -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 = ({ + users, + roles, + onSuspendUser, + onActivateUser, + onResetUserPassword, + onUpdateUserRoles, + isUpdatingUserRoles, + canEditUsers, + isLoading, +}) => { + const styles = useStyles() + + if (isLoading) { + return + } + + if (!users || users.length === 0) { + return ( + + + + + + + + ) + } + + 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 ( + + + + + + {user.status} + + + {canEditUsers ? ( + { + // 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(", ")} + )} + + {canEditUsers && ( + + + + )} + + ) + })} + + ) +} + +const useStyles = makeStyles((theme) => ({ + status: { + textTransform: "capitalize", + }, + suspended: { + color: theme.palette.text.secondary, + }, +})) diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 2b110737f27bd..5fd9031c0c705 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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 diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 7f44d5f786fa6..627c7fb0b4cfa 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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) + }} /> void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void + onFilter: (query: string) => void } export const UsersPageView: FC = ({ @@ -40,7 +45,14 @@ export const UsersPageView: FC = ({ canEditUsers, canCreateUser, isLoading, + filter, + onFilter, }) => { + const presetFilters = [ + { query: userFilterQuery.active, name: Language.activeUsersFilterName }, + { query: userFilterQuery.all, name: Language.allUsersFilterName }, + ] + return ( = ({ Users - {error ? ( - - ) : ( - - )} + + + ) } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 1f742967e5310..1e3987e6d9e6b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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", diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 9a19cc7c4d423..2c0a3c5602e66 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -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, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index fd6be81f43d1a..5443c1ef7e8ff 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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) diff --git a/site/src/util/filters.test.ts b/site/src/util/filters.test.ts new file mode 100644 index 0000000000000..a6d57d970f76c --- /dev/null +++ b/site/src/util/filters.test.ts @@ -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) + }) +}) diff --git a/site/src/util/filters.ts b/site/src/util/filters.ts new file mode 100644 index 0000000000000..461507411d7e5 --- /dev/null +++ b/site/src/util/filters.ts @@ -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: "", +} diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 3e4e707bd1f66..257882815ec9c 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -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) - }) - }) }) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 8587cf28358ef..2d7df1fda88f4 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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: "", -} diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index bab90367af264..4d22c9534771a 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -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) }, diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 6b605ce70bef9..466798e38ae5f 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -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)), }, }, )