8000 Handle filter form errors · coder/coder@b94dba8 · GitHub
[go: up one dir, main page]

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
  • pages/UsersPage
  • xServices/users
  • 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/ 10000 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 4D63 )
    370+
    },
    366371
    displayCreateUserSuccess: () => {
    367372
    displaySuccess(Language.createUserSuccess)
    368373
    },

    0 commit comments

    Comments
     (0)
    0