Skip to content

Commit be7eaac

Browse files
committed
Handle filter form errors
1 parent acbd54a commit be7eaac

File tree

8 files changed

+202
-83
lines changed

8 files changed

+202
-83
lines changed

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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,9 @@ export const getErrorMessage = (
7171
: error instanceof Error
7272
? error.message
7373
: defaultMessage
74+
75+
export const getValidationErrorMessage = (error: Error | ApiError | unknown): string => {
76+
const validationErrors =
77+
isApiError(error) && error.response.data.validations ? error.response.data.validations : []
78+
return validationErrors.map((error) => error.detail).join("\n")
79+
}

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

Lines changed: 18 additions & 0 deletions
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

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ 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+
{
41+
field: "status",
42+
detail: `Query param "status" has invalid value: "inactive" is not a valid user status`,
43+
},
44+
],
45+
},
46+
}

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 16 additions & 10 deletions
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

Lines changed: 18 additions & 17 deletions
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,25 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
6867
<PageHeaderTitle>Users</PageHeaderTitle>
6968
</PageHeader>
7069

71-
<SearchBarWithFilter filter={filter} onFilter={onFilter} presetFilters={presetFilters} />
70+
<SearchBarWithFilter
71+
filter={filter}
72+
onFilter={onFilter}
73+
presetFilters={presetFilters}
74+
error={error}
75+
/>
7276

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-
)}
77+
<UsersTable
78+
users={users}
79+
roles={roles}
80+
onSuspendUser={onSuspendUser}
81+
onActivateUser={onActivateUser}
82+
onResetUserPassword={onResetUserPassword}
83+
onUpdateUserRoles={onUpdateUserRoles}
84+
isUpdatingUserRoles={isUpdatingUserRoles}
85+
canEditUsers={canEditUsers}
86+
isLoading={isLoading}
87+
error={error}
88+
/>
8889
</Margins>
8990
)
9091
}

0 commit comments

Comments
 (0)