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

Skip to content

Commit db1ab2e

Browse files
committed
Handle filter form errors
1 parent e3e82d7 commit db1ab2e

File tree

8 files changed

+173
-79
lines changed

8 files changed

+173
-79
lines changed

site/src/api/errors.test.ts

+40-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,42 @@ describe("mapApiErrorToFieldErrors", () => {
3636
})
3737
})
3838
})
39+
40+
describe("getValidationErrorMessage", () => {
41+
it("returns multiple validation messages", () => {
42+
expect(
43+
getValidationErrorMessage({
44+
message: "Invalid user search query.",
45+
validations: [
46+
{
47+
field: "status",
48+
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
49+
},
50+
{
51+
field: "q",
52+
detail: `Query element "role:a:e" can only contain 1 ':'`,
53+
},
54+
],
55+
}),
56+
).toEqual(
57+
`Query param "status" has invalid value: "inactive" is not a valid user status\nQuery element "role:a:e" can only contain 1 ':'`,
58+
)
59+
})
60+
61+
it("non-API error returns empty validation message", () => {
62+
expect(
63+
getValidationErrorMessage({
64+
error: "Invalid user search query.",
65+
}),
66+
).toEqual("")
67+
})
68+
69+
it("no validations field returns empty validation message", () => {
70+
expect(
71+
getValidationErrorMessage({
72+
message: "Invalid user search query.",
73+
detail: `Query element "role:a:e" can only contain 1 ':'`,
74+
}),
75+
).toEqual("")
76+
})
77+
})

site/src/api/errors.ts

+5
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,8 @@ export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): Fi
6262
*/
6363
export const getErrorMessage = (error: Error | ApiError | unknown, defaultMessage: string): string =>
6464
isApiError(error) ? error.response.data.message : error instanceof Error ? error.message : defaultMessage
65+
66+
export const getValidationErrorMessage = (error: Error | ApiError | unknown): string => {
67+
const validationErrors = isApiError(error) && error.response.data.validations ? error.response.data.validations : []
68+
return validationErrors.map((error) => error.detail).join("\n")
69+
}

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

+18
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,21 @@ WithPresetFilters.args = {
2323
{ query: "random query", name: "Random query" },
2424
],
2525
}
26+
27+
export const WithError = Template.bind({})
28+
WithError.args = {
29+
presetFilters: [
30+
{ query: workspaceFilterQuery.me, name: "Your workspaces" },
31+
{ query: "random query", name: "Random query" },
32+
],
33+
error: {
34+
response: {
35+
data: {
36+
validations: {
37+
field: "status",
38+
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
39+
},
40+
},
41+
},
42+
},
43+
}

site/src/components/SearchBarWithFilter/SearchBarWithFilter.tsx

+62-50
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 {
@@ -33,7 +35,7 @@ interface FilterFormValues {
3335

3436
export type FilterFormErrors = FormikErrors<FilterFormValues>
3537

36-
export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({ filter, onFilter, presetFilters }) => {
38+
export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({ filter, onFilter, presetFilters, error }) => {
3739
const styles = useStyles()
3840

3941
const form = useFormik<FilterFormValues>({
@@ -64,64 +66,71 @@ export const SearchBarWithFilter: React.FC<SearchBarWithFilterProps> = ({ filter
6466
handleClose()
6567
}
6668

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

120127
const useStyles = makeStyles((theme) => ({
128+
root: {
129+
marginBottom: theme.spacing(2),
130+
},
121131
filterContainer: {
122132
border: `1px solid ${theme.palette.divider}`,
123133
borderRadius: theme.shape.borderRadius,
124-
marginBottom: theme.spacing(2),
125134
},
126135
filterForm: {
127136
width: "100%",
@@ -137,4 +146,7 @@ const useStyles = makeStyles((theme) => ({
137146
border: "none",
138147
},
139148
},
149+
errorRoot: {
150+
color: theme.palette.error.dark,
151+
},
140152
}))

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

+13
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,16 @@ Empty.args = {
2828
users: [],
2929
roles: MockSiteRoles,
3030
}
31+
32+
export const Error = Template.bind({})
33+
Error.args = {
34+
users: [MockUser, MockUser2],
35+
roles: MockSiteRoles,
36+
canEditUsers: true,
37+
error: {
38+
message: "Invalid user search query.",
39+
validations: [
40+
{ field: "status", detail: `Query param "status" has invalid value: "inactive" is not a valid user status` },
41+
],
42+
},
43+
}

site/src/components/UsersTable/UsersTable.tsx

+16-10
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface UsersTableProps {
3636
onActivateUser: (user: TypesGen.User) => void
3737
onResetUserPassword: (user: TypesGen.User) => void
3838
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
39+
error?: unknown
3940
}
4041

4142
export const UsersTable: FC<UsersTableProps> = ({
@@ -48,6 +49,7 @@ export const UsersTable: FC<UsersTableProps> = ({
4849
isUpdatingUserRoles,
4950
canEditUsers,
5051
isLoading,
52+
error,
5153
}) => {
5254
const styles = useStyles()
5355

@@ -63,8 +65,9 @@ export const UsersTable: FC<UsersTableProps> = ({
6365
</TableRow>
6466
</TableHead>
6567
<TableBody>
66-
{isLoading && <TableLoader />}
68+
{isLoading && !error && <TableLoader />}
6769
{!isLoading &&
70+
!error &&
6871
users &&
6972
users.map((user) => {
7073
// When the user has no role we want to show they are a Member
@@ -134,15 +137,18 @@ export const UsersTable: FC<UsersTableProps> = ({
134137
)
135138
})}
136139

137-
{users && users.length === 0 && (
138-
<TableRow>
139-
<TableCell colSpan={999}>
140-
<Box p={4}>
141-
<EmptyState message={Language.emptyMessage} />
142-
</Box>
143-
</TableCell>
144-
</TableRow>
145-
)}
140+
{
141+
// Default behavior for error state and empty list
142+
(error || (users && users.length === 0)) && (
143+
<TableRow>
144+
<TableCell colSpan={999}>
145+
<Box p={4}>
146+
<EmptyState message={Language.emptyMessage} />
147+
</Box>
148+
</TableCell>
149+
</TableRow>
150+
)
151+
}
146152
</TableBody>
147153
</Table>
148154
)

site/src/pages/UsersPage/UsersPageView.tsx

+13-17
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import Button from "@material-ui/core/Button"
22
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
33
import { FC } from "react"
44
import * as TypesGen from "../../api/typesGenerated"
5-
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
65
import { Margins } from "../../components/Margins/Margins"
76
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
87
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
@@ -68,23 +67,20 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
6867
<PageHeaderTitle>Users</PageHeaderTitle>
6968
</PageHeader>
7069

71-
<SearchBarWithFilter filter={filter} onFilter={onFilter} presetFilters={presetFilters} />
70+
<SearchBarWithFilter filter={filter} onFilter={onFilter} presetFilters={presetFilters} error={error} />
7271

73-
{error ? (
74-
<ErrorSummary error={error} />
75-
) : (
76-
<UsersTable
77-
users={users}
78-
roles={roles}
79-
onSuspendUser={onSuspendUser}
80-
onActivateUser={onActivateUser}
81-
onResetUserPassword={onResetUserPassword}
82-
onUpdateUserRoles={onUpdateUserRoles}
83-
isUpdatingUserRoles={isUpdatingUserRoles}
84-
canEditUsers={canEditUsers}
85-
isLoading={isLoading}
86-
/>
87-
)}
72+
<UsersTable
73+
users={users}
74+
roles={roles}
75+
onSuspendUser={onSuspendUser}
76+
onActivateUser={onActivateUser}
77+
onResetUserPassword={onResetUserPassword}
78+
onUpdateUserRoles={onUpdateUserRoles}
79+
isUpdatingUserRoles={isUpdatingUserRoles}
80+
canEditUsers={canEditUsers}
81+
isLoading={isLoading}
82+
error={error}
83+
/>
8884
</Margins>
8985
)
9086
}

site/src/xServices/users/usersXService.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { queryToFilter } from "../../util/filters"
1414
import { generateRandomString } from "../../util/random"
1515

1616
export const Language = {
17+
getUsersError: "Error getting users.",
1718
createUserSuccess: "Successfully created user.",
1819
createUserError: "Error on creating the user.",
1920
suspendUserSuccess: "Successfully suspended the user.",
@@ -135,7 +136,7 @@ export const usersMachine = createMachine(
135136
],
136137
onError: [
137138
{
138-
actions: "assignGetUsersError",
139+
actions: ["assignGetUsersError", "displayGetUsersErrorMessage"],
139140
target: "#usersState.error",
140141
},
141142
],
@@ -363,6 +364,10 @@ export const usersMachine = createMachine(
363364
clearUpdateUserRolesError: assign({
364365
updateUserRolesError: (_) => undefined,
365366
}),
367+
displayGetUsersErrorMessage: (context) => {
368+
const message = getErrorMessage(context.getUsersError, Language.getUsersError)
369+
displayError(message)
370+
},
366371
displayCreateUserSuccess: () => {
367372
displaySuccess(Language.createUserSuccess)
368373
},

0 commit comments

Comments
 (0)