Skip to content

Commit 88e30be

Browse files
feat: add the preferences/account page (coder#999)
1 parent c853eb3 commit 88e30be

File tree

15 files changed

+480
-25
lines changed

15 files changed

+480
-25
lines changed

site/jest.setup.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ beforeAll(() =>
99

1010
// Reset any request handlers that we may add during the tests,
1111
// so they don't affect other tests.
12-
afterEach(() => server.resetHandlers())
12+
afterEach(() => {
13+
server.resetHandlers()
14+
jest.clearAllMocks()
15+
})
1316

1417
// Clean up after the tests are finished.
1518
afterAll(() => server.close())

site/src/api/errors.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { isApiError, mapApiErrorToFieldErrors } from "./errors"
2+
3+
describe("isApiError", () => {
4+
it("returns true when the object is an API Error", () => {
5+
expect(
6+
isApiError({
7+
isAxiosError: true,
8+
response: {
9+
data: {
10+
message: "Invalid entry",
11+
errors: [{ detail: "Username is already in use", field: "username" }],
12+
},
13+
},
14+
}),
15+
).toBe(true)
16+
})
17+
18+
it("returns false when the object is Error", () => {
19+
expect(isApiError(new Error())).toBe(false)
20+
})
21+
22+
it("returns false when the object is undefined", () => {
23+
expect(isApiError(undefined)).toBe(false)
24+
})
25+
})
26+
27+
describe("mapApiErrorToFieldErrors", () => {
28+
it("returns correct field errors", () => {
29+
expect(
30+
mapApiErrorToFieldErrors({
31+
message: "Invalid entry",
32+
errors: [{ detail: "Username is already in use", field: "username" }],
33+
}),
34+
).toEqual({
35+
username: "Username is already in use",
36+
})
37+
})
38+
})

site/src/api/errors.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import axios, { AxiosError, AxiosResponse } from "axios"
2+
3+
export const Language = {
4+
errorsByCode: {
5+
defaultErrorCode: "Invalid value",
6+
},
7+
}
8+
9+
interface FieldError {
10+
field: string
11+
detail: string
12+
}
13+
14+
type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
15+
16+
export interface ApiErrorResponse {
17+
message: string
18+
errors?: FieldError[]
19+
}
20+
21+
export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }
22+
23+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
24+
export const isApiError = (err: any): err is ApiError => {
25+
if (axios.isAxiosError(err)) {
26+
const response = err.response?.data
27+
28+
return (
29+
typeof response.message === "string" && (typeof response.errors === "undefined" || Array.isArray(response.errors))
30+
)
31+
}
32+
33+
return false
34+
}
35+
36+
export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => {
37+
const result: FieldErrors = {}
38+
39+
if (apiErrorResponse.errors) {
40+
for (const error of apiErrorResponse.errors) {
41+
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode
42+
}
43+
}
44+
45+
return result
46+
}

site/src/api/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios, { AxiosRequestHeaders } from "axios"
22
import { mutate } from "swr"
3-
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
3+
import { MockPager, MockUser, MockUser2 } from "../test_helpers/entities"
44
import * as Types from "./types"
55

66
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
@@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async (
103103
headers: { ...CONTENT_TYPE_JSON },
104104
})
105105
}
106+
107+
export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.UserResponse> => {
108+
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
109+
return response.data
110+
}

site/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface UserResponse {
1515
readonly username: string
1616
readonly email: string
1717
readonly created_at: string
18+
readonly name: string
1819
}
1920

2021
/**
@@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
9596
export interface WorkspaceAutostopRequest {
9697
schedule: string
9798
}
99+
100+
export interface UpdateProfileRequest {
101+
readonly username: string
102+
readonly email: string
103+
readonly name: string
104+
}

site/src/components/Form/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ interface FormHelpers {
1717
helperText?: string
1818
}
1919

20-
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
20+
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string, error?: string): FormHelpers => {
2121
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
2222
const touched = getIn(form.touched, name)
23-
const errors = getIn(form.errors, name)
23+
const errors = error ?? getIn(form.errors, name)
2424
return {
2525
...form.getFieldProps(name),
2626
id: name,

site/src/components/Page/RequireAuth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
1515
const location = useLocation()
1616
const redirectTo = embedRedirect(location.pathname)
1717

18-
if (authState.matches("signedOut") || !authState.context.me) {
18+
if (authState.matches("signedOut")) {
1919
return <Navigate to={redirectTo} />
2020
} else if (authState.hasTag("loading")) {
2121
return <FullScreenLoader />
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import FormHelperText from "@material-ui/core/FormHelperText"
2+
import TextField from "@material-ui/core/TextField"
3+
import { FormikContextType, FormikErrors, useFormik } from "formik"
4+
import React from "react"
5+
import * as Yup from "yup"
6+
import { getFormHelpers, onChangeTrimmed } from "../Form"
7+
import { Stack } from "../Stack/Stack"
8+
import { LoadingButton } from "./../Button"
9+
10+
interface AccountFormValues {
11+
name: string
12+
email: string
13+
username: string
14+
}
15+
16+
export const Language = {
17+
nameLabel: "Name",
18+
usernameLabel: "Username",
19+
emailLabel: "Email",
20+
emailInvalid: "Please enter a valid email address.",
21+
emailRequired: "Please enter an email address.",
22+
updatePreferences: "Update preferences",
23+
}
24+
25+
const validationSchema = Yup.object({
26+
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
27+
name: Yup.string().optional(),
28+
username: Yup.string().trim(),
29+
})
30+
31+
export type AccountFormErrors = FormikErrors<AccountFormValues>
32+
export interface AccountFormProps {
33+
isLoading: boolean
34+
initialValues: AccountFormValues
35+
onSubmit: (values: AccountFormValues) => void
36+
formErrors?: AccountFormErrors
37+
error?: string
38+
}
39+
40+
export const AccountForm: React.FC<AccountFormProps> = ({
41+
isLoading,
42+
onSubmit,
43+
initialValues,
44+
formErrors = {},
45+
error,
46+
}) => {
47+
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
48+
initialValues,
49+
validationSchema,
50+
onSubmit,
51+
})
52+
53+
return (
54+
<>
55+
<form onSubmit={form.handleSubmit}>
56+
<Stack>
57+
<TextField
58+
{...getFormHelpers<AccountFormValues>(form, "name")}
59+
autoFocus
60+
autoComplete="name"
61+
fullWidth
62+
label={Language.nameLabel}
63+
variant="outlined"
64+
/>
65+
<TextField
66+
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
67+
onChange={onChangeTrimmed(form)}
68+
autoComplete="email"
69+
fullWidth
70+
label={Language.emailLabel}
71+
variant="outlined"
72+
/>
73+
<TextField
74+
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
75+
onChange={onChangeTrimmed(form)}
76+
autoComplete="username"
77+
fullWidth
78+
label={Language.usernameLabel}
79+
variant="outlined"
80+
/>
81+
82+
{error && <FormHelperText error>{error}</FormHelperText>}
83+
84+
<div>
85+
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
86+
{isLoading ? "" : Language.updatePreferences}
87+
</LoadingButton>
88+
</div>
89+
</Stack>
90+
</form>
91+
</>
92+
)
93+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import TextField from "@material-ui/core/TextField"
2+
import { Story } from "@storybook/react"
3+
import React from "react"
4+
import { Stack, StackProps } from "./Stack"
5+
6+
export default {
7+
title: "Components/Stack",
8+
component: Stack,
9+
}
10+
11+
const Template: Story<StackProps> = (args: StackProps) => (
12+
<Stack {...args}>
13+
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
14+
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
15+
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
16+
</Stack>
17+
)
18+
19+
export const Example = Template.bind({})
20+
Example.args = {
21+
spacing: 2,
22+
}

site/src/components/Stack/Stack.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import React from "react"
3+
4+
export interface StackProps {
5+
spacing?: number
6+
}
7+
8+
const useStyles = makeStyles((theme) => ({
9+
stack: {
10+
display: "flex",
11+
flexDirection: "column",
12+
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
13+
},
14+
}))
15+
16+
export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
17+
const styles = useStyles({ spacing })
18+
return <div className={styles.stack}>{children}</div>
19+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { fireEvent, screen, waitFor } from "@testing-library/react"
2+
import React from "react"
3+
import * as API from "../../api"
4+
import * as AccountForm from "../../components/Preferences/AccountForm"
5+
import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar"
6+
import { renderWithAuth } from "../../test_helpers"
7+
import * as AuthXService from "../../xServices/auth/authXService"
8+
import { Language, PreferencesAccountPage } from "./account"
9+
10+
const renderPage = () => {
11+
return renderWithAuth(
12+
<>
13+
<PreferencesAccountPage />
14+
<GlobalSnackbar />
15+
</>,
16+
)
17+
}
18+
19+
const newData = {
20+
name: "User",
21+
email: "user@coder.com",
22+
username: "user",
23+
}
24+
25+
const fillAndSubmitForm = async () => {
26+
await waitFor(() => screen.findByLabelText("Name"))
27+
fireEvent.change(screen.getByLabelText("Name"), { target: { value: newData.name } })
28+
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } })
29+
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } })
30+
fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences))
31+
}
32+
33+
describe("PreferencesAccountPage", () => {
34+
describe("when it is a success", () => {
35+
it("shows the success message", async () => {
36+
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
37+
Promise.resolve({
38+
id: userId,
39+
...data,
40+
created_at: new Date().toString(),
41+
}),
42+
)
43+
const { user } = renderPage()
44+
await fillAndSubmitForm()
45+
46+
const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate)
47+
expect(successMessage).toBeDefined()
48+
expect(API.updateProfile).toBeCalledTimes(1)
49+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
50+
})
51+
})
52+
53+
describe("when the email is already taken", () => {
54+
it("shows an error", async () => {
55+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
56+
isAxiosError: true,
57+
response: {
58+
data: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] },
59+
},
60+
})
61+
62+
const { user } = renderPage()
63+
await fillAndSubmitForm()
64+
65+
const errorMessage = await screen.findByText("Email is already in use")
66+
expect(errorMessage).toBeDefined()
67+
expect(API.updateProfile).toBeCalledTimes(1)
68+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
69+
})
70+
})
71+
72+
describe("when the username is already taken", () => {
73+
it("shows an error", async () => {
74+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
75+
isAxiosError: true,
76+
response: {
77+
data: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] },
78+
},
79+
})
80+
81+
const { user } = renderPage()
82+
await fillAndSubmitForm()
83+
84+
const errorMessage = await screen.findByText("Username is already in use")
85+
expect(errorMessage).toBeDefined()
86+
expect(API.updateProfile).toBeCalledTimes(1)
87+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
88+
})
89+
})
90+
91+
describe("when it is an unknown error", () => {
92+
it("shows a generic error message", async () => {
93+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
94+
data: "unknown error",
95+
})
96+
97+
const { user } = renderPage()
98+
await fillAndSubmitForm()
99+
100+
const errorMessage = await screen.findByText(Language.unknownError)
101+
expect(errorMessage).toBeDefined()
102+
expect(API.updateProfile).toBeCalledTimes(1)
103+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
104+
})
105+
})
106+
})

0 commit comments

Comments
 (0)