Skip to content

Commit b94dba8

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

File tree

8 files changed

+188
-79
lines changed

8 files changed

+188
-79
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: 5 additions & 0 deletions
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

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: 62 additions & 50 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 {
@@ -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

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

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: 13 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,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

Lines changed: 6 additions & 1 deletion
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)
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