Skip to content

Commit 0d25e17

Browse files
authored
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.
1 parent cb2d1f4 commit 0d25e17

22 files changed

+503
-249
lines changed

site/src/api/api.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import axios from "axios"
2-
import { getApiKey, getWorkspacesURL, login, logout } from "./api"
2+
import { getApiKey, getURLWithSearchParams, login, logout } from "./api"
33
import * as TypesGen from "./typesGenerated"
44

55
describe("api.ts", () => {
@@ -114,16 +114,26 @@ describe("api.ts", () => {
114114
})
115115
})
116116

117-
describe("getWorkspacesURL", () => {
118-
it.each<[TypesGen.WorkspaceFilter | undefined, string]>([
119-
[undefined, "/api/v2/workspaces"],
117+
describe("getURLWithSearchParams - workspaces", () => {
118+
it.each<[string, TypesGen.WorkspaceFilter | undefined, string]>([
119+
["/api/v2/workspaces", undefined, "/api/v2/workspaces"],
120120

121-
[{ q: "" }, "/api/v2/workspaces"],
122-
[{ q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"],
121+
["/api/v2/workspaces", { q: "" }, "/api/v2/workspaces"],
122+
["/api/v2/workspaces", { q: "owner:1" }, "/api/v2/workspaces?q=owner%3A1"],
123123

124-
[{ q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"],
125-
])(`getWorkspacesURL(%p) returns %p`, (filter, expected) => {
126-
expect(getWorkspacesURL(filter)).toBe(expected)
124+
["/api/v2/workspaces", { q: "owner:me" }, "/api/v2/workspaces?q=owner%3Ame"],
125+
])(`Workspaces - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => {
126+
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
127+
})
128+
})
129+
130+
describe("getURLWithSearchParams - users", () => {
131+
it.each<[string, TypesGen.UsersRequest | undefined, string]>([
132+
["/api/v2/users", undefined, "/api/v2/users"],
133+
["/api/v2/users", { q: "status:active" }, "/api/v2/users?q=status%3Aactive"],
134+
["/api/v2/users", { q: "" }, "/api/v2/users"],
135+
])(`Users - getURLWithSearchParams(%p, %p) returns %p`, (basePath, filter, expected) => {
136+
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
127137
})
128138
})
129139
})

site/src/api/api.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ export const getApiKey = async (): Promise<TypesGen.GenerateAPIKeyResponse> => {
7272
return response.data
7373
}
7474

75-
export const getUsers = async (): Promise<TypesGen.User[]> => {
76-
const response = await axios.get<TypesGen.User[]>("/api/v2/users?q=status:active,suspended")
75+
export const getUsers = async (filter?: TypesGen.UsersRequest): Promise<TypesGen.User[]> => {
76+
const url = getURLWithSearchParams("/api/v2/users", filter)
77+
const response = await axios.get<TypesGen.User[]>(url)
7778
return response.data
7879
}
7980

@@ -144,8 +145,10 @@ export const getWorkspace = async (
144145
return response.data
145146
}
146147

147-
export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
148-
const basePath = "/api/v2/workspaces"
148+
export const getURLWithSearchParams = (
149+
basePath: string,
150+
filter?: TypesGen.WorkspaceFilter | TypesGen.UsersRequest,
151+
): string => {
149152
const searchParams = new URLSearchParams()
150153

151154
if (filter?.q && filter.q !== "") {
@@ -160,7 +163,7 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
160163
export const getWorkspaces = async (
161164
filter?: TypesGen.WorkspaceFilter,
162165
): Promise<TypesGen.Workspace[]> => {
163-
const url = getWorkspacesURL(filter)
166+
const url = getURLWithSearchParams("/api/v2/workspaces", filter)
164167
const response = await axios.get<TypesGen.Workspace[]>(url)
165168
return response.data
166169
}

site/src/api/errors.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isApiError, mapApiErrorToFieldErrors } from "./errors"
1+
import { getValidationErrorMessage, isApiError, mapApiErrorToFieldErrors } from "./errors"
22

33
describe("isApiError", () => {
44
it("returns true when the object is an API Error", () => {
@@ -36,3 +36,57 @@ describe("mapApiErrorToFieldErrors", () => {
3636
})
3737
})
3838
})
39+
40+
describe("getValidationErrorMessage", () => {
41+
it("returns multiple validation messages", () => {
42+
expect(
43+
getValidationErrorMessage({
44+
response: {
45+
data: {
46+
message: "Invalid user search query.",
47+
validations: [
48+
{
49+
field: "status",
50+
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
51+
},
52+
{
53+
field: "q",
54+
detail: `Query element "role:a:e" can only contain 1 ':'`,
55+
},
56+
],
57+
},
58+
},
59+
isAxiosError: true,
60+
}),
61+
).toEqual(
62+
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
63+
)
64+
})
65+
66+
it("non-API error returns empty validation message", () => {
67+
expect(
68+
getValidationErrorMessage({
69+
response: {
70+
data: {
71+
error: "Invalid user search query.",
72+
},
73+
},
74+
isAxiosError: true,
75+
}),
76+
).toEqual("")
77+
})
78+
79+
it("no validations field returns empty validation message", () => {
80+
expect(
81+
getValidationErrorMessage({
82+
response: {
83+
data: {
84+
message: "Invalid user search query.",
85+
detail: `Query element "role:a:e" can only contain 1 ':'`,
86+
},
87+
},
88+
isAxiosError: true,
89+
}),
90+
).toEqual("")
91+
})
92+
})

site/src/api/errors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ export const getErrorMessage = (
7171
: error instanceof Error
7272
? error.message
7373
: defaultMessage
74+
75+
/**
76+
*
77+
* @param error
78+
* @returns a combined validation error message if the error is an ApiError
79+
* and contains validation messages for different form fields.
80+
*/
81+
export const getValidationErrorMessage = (error: Error | ApiError | unknown): string => {
82+
const validationErrors =
83+
isApiError(error) && error.response.data.validations ? error.response.data.validations : []
84+
return validationErrors.map((error) => error.detail).join("\n")
85+
}

site/src/components/SearchBarWithFilter/SearchBarWithFilter.stories.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ComponentMeta, Story } from "@storybook/react"
2-
import { workspaceFilterQuery } from "../../util/workspace"
2+
import { userFilterQuery, workspaceFilterQuery } from "../../util/filters"
33
import { SearchBarWithFilter, SearchBarWithFilterProps } from "./SearchBarWithFilter"
44

55
export default {
@@ -23,3 +23,26 @@ WithPresetFilters.args = {
2323
{ query: "random query", name: "Random query" },
2424
],
2525
}
26+
27+
export const WithError = Template.bind({})
28+
WithError.args = {
29+
filter: "status:inactive",
30+
presetFilters: [
31+
{ query: userFilterQuery.active, name: "Active users" },
32+
{ query: "random query", name: "Random query" },
33+
],
34+
error: {
35+
response: {
36+
data: {
37+
message: "Invalid user search query.",
38+
validations: [
39+
{
40+
field: "status",
41+
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
42+
},
43+
],
44+
},
45+
},
46+
isAxiosError: true,
47+
},
48+
}

site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx

Lines changed: 67 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import TextField from "@material-ui/core/TextField"
88
import SearchIcon from "@material-ui/icons/Search"
99
import { FormikErrors, useFormik } from "formik"
1010
import { useState } from "react"
11+
import { getValidationErrorMessage } from "../../api/errors"
1112
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
1213
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
1314
import { Stack } from "../Stack/Stack"
@@ -20,6 +21,7 @@ export interface SearchBarWithFilterProps {
2021
filter?: string
2122
onFilter: (query: string) => void
2223
presetFilters?: PresetFilter[]
24+
error?: unknown
2325
}
2426

2527
export interface PresetFilter {
@@ -37,6 +39,7 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
3739
filter,
3840
onFilter,
3941
presetFilters,
42+
error,
4043
}) => {
4144
const styles = useStyles()
4245

@@ -68,69 +71,76 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({
6871
handleClose()
6972
}
7073

74+
const errorMessage = getValidationErrorMessage(error)
75+
7176
return (
72-
<Stack direction="row" spacing={0} className={styles.filterContainer}>
73-
{presetFilters && presetFilters.length > 0 && (
74-
<Button
75-
aria-controls="filter-menu"
76-
aria-haspopup="true"
77-
onClick={handleClick}
78-
className={styles.buttonRoot}
79-
>
80-
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
81-
</Button>
82-
)}
83-
84-
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
85-
<TextField
86-
{...getFieldHelpers("query")}
87-
className={styles.textFieldRoot}
88-
onChange={onChangeTrimmed(form)}
89-
fullWidth
90-
variant="outlined"
91-
InputProps={{
92-
startAdornment: (
93-
<InputAdornment position="start">
94-
<SearchIcon fontSize="small" />
95-
</InputAdornment>
96-
),
97-
}}
98-
/>
99-
</form>
100-
101-
{presetFilters && presetFilters.length > 0 && (
102-
<Menu
103-
id="filter-menu"
104-
anchorEl={anchorEl}
105-
keepMounted
106-
open={Boolean(anchorEl)}
107-
onClose={handleClose}
108-
TransitionComponent={Fade}
109-
anchorOrigin={{
110-
vertical: "bottom",
111-
horizontal: "left",
112-
}}
113-
transformOrigin={{
114-
vertical: "top",
115-
horizontal: "left",
116-
}}
117-
>
118-
{presetFilters.map((presetFilter) => (
119-
<MenuItem key={presetFilter.name} onClick={setPresetFilter(presetFilter.query)}>
120-
{presetFilter.name}
121-
</MenuItem>
122-
))}
123-
</Menu>
124-
)}
77+
<Stack spacing={1} className={styles.root}>
78+
<Stack direction="row" spacing={0} className={styles.filterContainer}>
79+
{presetFilters && presetFilters.length > 0 && (
80+
<Button
81+
aria-controls="filter-menu"
82+
aria-haspopup="true"
83+
onClick={handleClick}
84+
className={styles.buttonRoot}
85+
>
86+
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
87+
</Button>
88+
)}
89+
90+
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
91+
<TextField
92+
{...getFieldHelpers("query")}
93+
className={styles.textFieldRoot}
94+
onChange={onChangeTrimmed(form)}
95+
fullWidth
96+
variant="outlined"
97+
InputProps={{
98+
startAdornment: (
99+
<InputAdornment position="start">
100+
<SearchIcon fontSize="small" />
101+
</InputAdornment>
102+
),
103+
}}
104+
/>
105+
</form>
106+
107+
{presetFilters && presetFilters.length > 0 && (
108+
<Menu
109+
id="filter-menu"
110+
anchorEl={anchorEl}
111+
keepMounted
112+
open={Boolean(anchorEl)}
113+
onClose={handleClose}
114+
TransitionComponent={Fade}
115+
anchorOrigin={{
116+
vertical: "bottom",
117+
horizontal: "left",
118+
}}
119+
transformOrigin={{
120+
vertical: "top",
121+
horizontal: "left",
122+
}}
123+
>
124+
{presetFilters.map((presetFilter) => (
125+
<MenuItem key={presetFilter.name} onClick={setPresetFilter(presetFilter.query)}>
126+
{presetFilter.name}
127+
</MenuItem>
128+
))}
129+
</Menu>
130+
)}
131+
</Stack>
132+
{errorMessage && <Stack className={styles.errorRoot}>{errorMessage}</Stack>}
125133
</Stack>
126134
)
127135
}
128136

129137
const useStyles = makeStyles((theme) => ({
138+
root: {
139+
marginBottom: theme.spacing(2),
140+
},
130141
filterContainer: {
131142
border: `1px solid ${theme.palette.divider}`,
132143
borderRadius: theme.shape.borderRadius,
133-
marginBottom: theme.spacing(2),
134144
},
135145
filterForm: {
136146
width: "100%",
@@ -146,4 +156,7 @@ const useStyles = makeStyles((theme) => ({
146156
border: "none",
147157
},
148158
},
159+
errorRoot: {
160+
color: theme.palette.error.dark,
161+
},
149162
}))

site/src/components/UsersTable/UsersTable.stories.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ Empty.args = {
2828
users: [],
2929
roles: MockSiteRoles,
3030
}
31+
32+
export const Loading = Template.bind({})
33+
Loading.args = {
34+
users: [],
35+
roles: MockSiteRoles,
36+
isLoading: true,
37+
}
38+
Loading.parameters = {
39+
chromatic: { pauseAnimationAtEnd: true },
40+
}

0 commit comments

Comments
 (0)
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