Skip to content

Commit c97313b

Browse files
committed
Handle filter form errors
1 parent 456e04f commit c97313b

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