Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0d25e17

Browse files
authored
feat: Add filter on Users page (coder#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

+19-9
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

+8-5
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

+55-1
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

+12
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

+24-1
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

+67-54
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

+10
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)