From acbd54ab93762867b67c499e1546efce8ef9fd97 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Fri, 24 Jun 2022 22:24:17 +0000 Subject: [PATCH 1/6] Add filter search on Users page --- site/src/api/api.test.ts | 21 +++++++++++-------- site/src/api/api.ts | 13 +++++++----- .../SearchBarWithFilter.stories.tsx | 2 +- site/src/pages/UsersPage/UsersPage.tsx | 17 +++++++++++++-- site/src/pages/UsersPage/UsersPageView.tsx | 15 +++++++++++++ .../pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- .../WorkspacesPageView.stories.tsx | 2 +- .../WorkspacesPage/WorkspacesPageView.tsx | 3 ++- site/src/util/filters.test.ts | 17 +++++++++++++++ site/src/util/filters.ts | 18 ++++++++++++++++ site/src/util/workspace.test.ts | 21 +------------------ site/src/util/workspace.ts | 12 ----------- site/src/xServices/users/usersXService.ts | 19 +++++++++++++---- .../workspaces/workspacesXService.ts | 4 ++-- 14 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 site/src/util/filters.test.ts create mode 100644 site/src/util/filters.ts diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index f7455a9910b32..d1ee841af6762 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,19 @@ describe("api.ts", () => { }) }) - describe("getWorkspacesURL", () => { - it.each<[TypesGen.WorkspaceFilter | undefined, string]>([ - [undefined, "/api/v2/workspaces"], + describe("getURLWithSearchParams", () => { + it.each<[string, TypesGen.WorkspaceFilter | TypesGen.UsersRequest | 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"], + + ["/api/v2/users", { q: "status:active" }, "/api/v2/users?q=status%3Aactive"], + ["/api/v2/users", { q: "" }, "/api/v2/users"], + ])(`getURLWithSearchParams(%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/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx index 2df5a0d485c82..6096155bfcdd8 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 { workspaceFilterQuery } from "../../util/filters" import { SearchBarWithFilter, SearchBarWithFilterProps } from "./SearchBarWithFilter" export default { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 7f44d5f786fa6..e3a3390de848a 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -2,8 +2,10 @@ import { useActor, useSelector } 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" @@ -31,6 +33,7 @@ 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) @@ -46,8 +49,13 @@ export const UsersPage: React.FC = () => { // Fetch users on component mount useEffect(() => { - usersSend("GET_USERS") - }, [usersSend]) + const filter = searchParams.get("filter") + const query = filter !== null ? filter : userFilterQuery.active + usersSend({ + type: "GET_USERS", + query, + }) + }, [searchParams, usersSend]) // Fetch roles on component mount useEffect(() => { @@ -91,6 +99,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 +46,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..91ec51927df9d 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" 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..27f7b6935533e 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -10,6 +10,7 @@ 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 = { @@ -28,6 +29,7 @@ export const Language = { export interface UsersContext { // Get users users?: TypesGen.User[] + filter?: string getUsersError?: Error | unknown createUserErrorMessage?: string createUserFormErrors?: FieldErrors @@ -47,7 +49,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 +99,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: { @@ -242,7 +247,10 @@ export const usersMachine = createMachine( }, error: { on: { - GET_USERS: "gettingUsers", + GET_USERS: { + actions: "assignFilter", + target: "gettingUsers", + }, }, }, }, @@ -252,7 +260,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 +305,9 @@ export const usersMachine = createMachine( assignUsers: assign({ users: (_, event) => event.data, }), + assignFilter: assign({ + filter: (_, event) => event.query, + }), assignGetUsersError: assign({ getUsersError: (_, event) => event.data, }), 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)), }, }, ) From be7eaacad2a228fea6af9b731c9af6327342c874 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Sat, 25 Jun 2022 21:34:49 +0000 Subject: [PATCH 2/6] Handle filter form errors --- site/src/api/errors.test.ts | 56 +++++++- site/src/api/errors.ts | 6 + .../SearchBarWithFilter.stories.tsx | 18 +++ .../SearchBarWithFilter.tsx | 121 ++++++++++-------- .../UsersTable/UsersTable.stories.tsx | 16 +++ site/src/components/UsersTable/UsersTable.tsx | 26 ++-- site/src/pages/UsersPage/UsersPageView.tsx | 35 ++--- site/src/xServices/users/usersXService.ts | 7 +- 8 files changed, 202 insertions(+), 83 deletions(-) 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..649bdabee7cb0 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -71,3 +71,9 @@ export const getErrorMessage = ( : error instanceof Error ? error.message : defaultMessage + +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 6096155bfcdd8..c44eed4e81bb8 100644 --- a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx +++ b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx @@ -23,3 +23,21 @@ WithPresetFilters.args = { { query: "random query", name: "Random query" }, ], } + +export const WithError = Template.bind({}) +WithError.args = { + presetFilters: [ + { query: workspaceFilterQuery.me, name: "Your workspaces" }, + { query: "random query", name: "Random query" }, + ], + error: { + response: { + data: { + validations: { + field: "status", + detail: `Query param "status" has invalid value: "inactive" is not a valid user status`, + }, + }, + }, + }, +} 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..7e858c54ca614 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -28,3 +28,19 @@ Empty.args = { users: [], roles: MockSiteRoles, } + +export const Error = Template.bind({}) +Error.args = { + users: [MockUser, MockUser2], + roles: MockSiteRoles, + canEditUsers: true, + error: { + message: "Invalid user search query.", + validations: [ + { + field: "status", + detail: `Query param "status" has invalid value: "inactive" is not a valid user status`, + }, + ], + }, +} diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 14ab43418d26a..3a65e334da31c 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -36,6 +36,7 @@ export interface UsersTableProps { onActivateUser: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void + error?: unknown } export const UsersTable: FC = ({ @@ -48,6 +49,7 @@ export const UsersTable: FC = ({ isUpdatingUserRoles, canEditUsers, isLoading, + error, }) => { const styles = useStyles() @@ -63,8 +65,9 @@ export const UsersTable: FC = ({ - {isLoading && } + {isLoading && !error && } {!isLoading && + !error && users && users.map((user) => { // When the user has no role we want to show they are a Member @@ -134,15 +137,18 @@ export const UsersTable: FC = ({ ) })} - {users && users.length === 0 && ( - - - - - - - - )} + { + // Default behavior for error state and empty list + (error || (users && users.length === 0)) && ( + + + + + + + + ) + } ) diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 93f6c358fc3c2..9f1fe3bc55905 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -2,7 +2,6 @@ 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" @@ -68,23 +67,25 @@ export const UsersPageView: FC = ({ Users - + - {error ? ( - - ) : ( - - )} +
) } diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 27f7b6935533e..060fa181a51be 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -14,6 +14,7 @@ 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.", @@ -135,7 +136,7 @@ export const usersMachine = createMachine( ], onError: [ { - actions: "assignGetUsersError", + actions: ["assignGetUsersError", "displayGetUsersErrorMessage"], target: "#usersState.error", }, ], @@ -364,6 +365,10 @@ export const usersMachine = createMachine( clearUpdateUserRolesError: assign({ updateUserRolesError: (_) => undefined, }), + displayGetUsersErrorMessage: (context) => { + const message = getErrorMessage(context.getUsersError, Language.getUsersError) + displayError(message) + }, displayCreateUserSuccess: () => { displaySuccess(Language.createUserSuccess) }, From 186425e14721a19ffb77f4fc30471e63747f2537 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Sat, 25 Jun 2022 21:58:02 +0000 Subject: [PATCH 3/6] Fix stories --- .../SearchBarWithFilter.stories.tsx | 17 +++++++++++------ .../UsersTable/UsersTable.stories.tsx | 17 +++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx b/site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx index c44eed4e81bb8..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/filters" +import { userFilterQuery, workspaceFilterQuery } from "../../util/filters" import { SearchBarWithFilter, SearchBarWithFilterProps } from "./SearchBarWithFilter" export default { @@ -26,18 +26,23 @@ WithPresetFilters.args = { export const WithError = Template.bind({}) WithError.args = { + filter: "status:inactive", presetFilters: [ - { query: workspaceFilterQuery.me, name: "Your workspaces" }, + { query: userFilterQuery.active, name: "Active users" }, { query: "random query", name: "Random query" }, ], error: { response: { data: { - validations: { - field: "status", - detail: `Query param "status" has invalid value: "inactive" is not a valid user status`, - }, + 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/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index 7e858c54ca614..fc589570717f9 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -35,12 +35,17 @@ Error.args = { roles: MockSiteRoles, canEditUsers: true, error: { - message: "Invalid user search query.", - validations: [ - { - field: "status", - detail: `Query param "status" has invalid value: "inactive" is not a valid user status`, + 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, }, } From bfbf72925e94222dcbc8b2fc27bf0ad36dd0f4ad Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Tue, 28 Jun 2022 07:43:00 +0000 Subject: [PATCH 4/6] refactor code --- site/src/api/api.test.ts | 13 ++++++++++--- site/src/api/errors.ts | 6 ++++++ site/src/pages/UsersPage/UsersPage.tsx | 2 +- site/src/pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index d1ee841af6762..83c02f0ccf23f 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -114,18 +114,25 @@ describe("api.ts", () => { }) }) - describe("getURLWithSearchParams", () => { - it.each<[string, TypesGen.WorkspaceFilter | TypesGen.UsersRequest | undefined, string]>([ + describe("getURLWithSearchParams - workspaces", () => { + it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([ ["/api/v2/workspaces", undefined, "/api/v2/workspaces"], ["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"], ["/api/v2/workspaces", { q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"], ["/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"], - ])(`getURLWithSearchParams(%p) returns %p`, (basePath, filter, expected) => { + ])(`Users - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => { expect(getURLWithSearchParams(basePath, filter)).toBe(expected) }) }) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 649bdabee7cb0..bc981bbafb256 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -72,6 +72,12 @@ export const getErrorMessage = ( ? 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 : [] diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index e3a3390de848a..689396a4f6192 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -50,7 +50,7 @@ export const UsersPage: React.FC = () => { // Fetch users on component mount useEffect(() => { const filter = searchParams.get("filter") - const query = filter !== null ? filter : userFilterQuery.active + const query = filter ?? userFilterQuery.active usersSend({ type: "GET_USERS", query, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 91ec51927df9d..1e3987e6d9e6b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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", From 3f309aac119461a21c8f4cfcae7930a8e8921757 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Tue, 28 Jun 2022 19:51:24 +0000 Subject: [PATCH 5/6] refactor UsersTable into separate components --- .../UsersTable/UsersTable.stories.tsx | 22 +-- site/src/components/UsersTable/UsersTable.tsx | 123 ++------------- .../components/UsersTable/UsersTableBody.tsx | 142 ++++++++++++++++++ site/src/pages/UsersPage/UsersPage.test.tsx | 8 +- site/src/pages/UsersPage/UsersPage.tsx | 22 ++- .../pages/UsersPage/UsersPageView.stories.tsx | 20 +++ site/src/pages/UsersPage/UsersPageView.tsx | 1 - site/src/xServices/users/usersXService.ts | 9 +- 8 files changed, 203 insertions(+), 144 deletions(-) create mode 100644 site/src/components/UsersTable/UsersTableBody.tsx diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index fc589570717f9..ad2a2129d2178 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -29,23 +29,9 @@ Empty.args = { roles: MockSiteRoles, } -export const Error = Template.bind({}) -Error.args = { - users: [MockUser, MockUser2], +export const Loading = Template.bind({}) +Loading.args = { + users: [], roles: MockSiteRoles, - canEditUsers: true, - 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, - }, + isLoading: true, } diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 3a65e334da31c..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", } @@ -36,7 +23,6 @@ export interface UsersTableProps { onActivateUser: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void - error?: unknown } export const UsersTable: FC = ({ @@ -49,10 +35,7 @@ export const UsersTable: FC = ({ isUpdatingUserRoles, canEditUsers, isLoading, - error, }) => { - const styles = useStyles() - return ( @@ -65,100 +48,18 @@ export const UsersTable: FC = ({ - {isLoading && !error && } - {!isLoading && - !error && - 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 && ( - - - - )} - - ) - })} - - { - // Default behavior for error state and empty list - (error || (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 689396a4f6192..627c7fb0b4cfa 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,4 +1,4 @@ -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" @@ -7,7 +7,6 @@ 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" @@ -23,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, @@ -37,15 +35,23 @@ export const UsersPage: React.FC = () => { 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(() => { diff --git a/site/src/pages/UsersPage/UsersPageView.stories.tsx b/site/src/pages/UsersPage/UsersPageView.stories.tsx index 965e412d9ca35..12fe898c05848 100644 --- a/site/src/pages/UsersPage/UsersPageView.stories.tsx +++ b/site/src/pages/UsersPage/UsersPageView.stories.tsx @@ -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, + }, +} diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9f1fe3bc55905..95015a9ba4578 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -84,7 +84,6 @@ export const UsersPageView: FC = ({ isUpdatingUserRoles={isUpdatingUserRoles} canEditUsers={canEditUsers} isLoading={isLoading} - error={error} /> ) diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 060fa181a51be..4d22c9534771a 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -125,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", "displayGetUsersErrorMessage"], + actions: ["clearUsers", "assignGetUsersError", "displayGetUsersErrorMessage"], target: "#usersState.error", }, ], @@ -348,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, From a7f7171f5446fc2da26fa1cee24ad5d6f8a14df2 Mon Sep 17 00:00:00 2001 From: Abhineet Jain Date: Tue, 28 Jun 2022 22:29:29 +0000 Subject: [PATCH 6/6] pause animation for isLoading story --- site/src/components/UsersTable/UsersTable.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/components/UsersTable/UsersTable.stories.tsx b/site/src/components/UsersTable/UsersTable.stories.tsx index ad2a2129d2178..7219993daf2d6 100644 --- a/site/src/components/UsersTable/UsersTable.stories.tsx +++ b/site/src/components/UsersTable/UsersTable.stories.tsx @@ -35,3 +35,6 @@ Loading.args = { roles: MockSiteRoles, isLoading: true, } +Loading.parameters = { + chromatic: { pauseAnimationAtEnd: true }, +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy