Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
19579eb
feat: Add account form
BrunoQuaresma Apr 12, 2022
9ab1f96
chore: Merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresma Apr 12, 2022
a23cc88
Merge branch 'main' of github.com:coder/coder into bq/755/account-page
BrunoQuaresma Apr 12, 2022
f9f1c5a
feat: Add account form
BrunoQuaresma Apr 13, 2022
1bbaf8a
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresma Apr 13, 2022
fa1d0e6
feat: show notification when preferences are updated
BrunoQuaresma Apr 13, 2022
1278ed6
test: account form submission with success
BrunoQuaresma Apr 13, 2022
7ccf811
chore: remove unecessary timeout
BrunoQuaresma Apr 13, 2022
2ae0987
test: add tests
BrunoQuaresma Apr 13, 2022
4595186
style: fix message copy
BrunoQuaresma Apr 14, 2022
fa91276
style: improve success message
BrunoQuaresma Apr 14, 2022
fc01ff8
refactor: name is not optional
BrunoQuaresma Apr 14, 2022
f3fedd0
chore: move renderWithAuth to test_hepers/index.tsx
BrunoQuaresma Apr 14, 2022
807d4e9
chore: move error types and utils to api/errors.ts
BrunoQuaresma Apr 14, 2022
fa580c7
test: use userEvent
BrunoQuaresma Apr 14, 2022
3d76331
fix: remove async from onSubmit
BrunoQuaresma Apr 14, 2022
37bc235
refactor: improve error types
BrunoQuaresma Apr 14, 2022
17a0b16
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresma Apr 14, 2022
0e8ac63
chore: merge branch 'bq/755/account-page' of github.com:coder/coder i…
BrunoQuaresma Apr 14, 2022
12058f8
refactor: api errors
BrunoQuaresma Apr 14, 2022
e489210
refactor: move UPDATE_PROFILE to idle state
BrunoQuaresma Apr 14, 2022
8098628
refactor: change FormStack to Stack and add storybook
BrunoQuaresma Apr 14, 2022
1f23e30
fix: error handling and tests
BrunoQuaresma Apr 14, 2022
a0588d1
feat: handle unknown error
BrunoQuaresma Apr 14, 2022
b3159d0
fix: make the eslint-disable inline
BrunoQuaresma Apr 14, 2022
e07d717
chore: rename story
BrunoQuaresma Apr 14, 2022
4d7da77
chore: merge branch 'bq/755/account-page' of github.com:coder/coder i…
BrunoQuaresma Apr 14, 2022
8d63848
Update site/src/xServices/auth/authXService.ts
BrunoQuaresma Apr 14, 2022
a11ff10
Update site/src/pages/preferences/account.tsx
BrunoQuaresma Apr 14, 2022
bde7c15
Fix errors
BrunoQuaresma Apr 15, 2022
684b902
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresma Apr 15, 2022
bbf2152
Fix type
BrunoQuaresma Apr 15, 2022
7f32600
Fix forms
BrunoQuaresma Apr 15, 2022
eb65490
Normalize machine
BrunoQuaresma Apr 15, 2022
59bac76
Fix: tests
BrunoQuaresma Apr 15, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
test: add tests
  • Loading branch information
BrunoQuaresma committed Apr 13, 2022
commit 2ae098749e06a5b44ccccb57652e523214225845
29 changes: 29 additions & 0 deletions site/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import axios, { AxiosRequestHeaders } from "axios"
import { mutate } from "swr"
import * as Types from "./types"

export const Language = {
errorsByCode: {
default: "Invalid value",
exists: "This value is already in use",
},
}

const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
"Content-Type": "application/json",
}
Expand Down Expand Up @@ -78,3 +85,25 @@ export const updateProfile = async (userId: string, data: Types.UpdateProfileReq
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
return response.data
}

const getApiError = (error: unknown): Types.ApiError | undefined => {
if (axios.isAxiosError(error)) {
return error.response?.data
}
}

export const getFormErrorsFromApiError = (error: unknown): Record<string, string> | undefined => {
const apiError = getApiError(error)

if (apiError && apiError.errors) {
return apiError.errors.reduce((errors, error) => {
return {
...errors,
[error.field]:
error.code in Language.errorsByCode
? Language.errorsByCode[error.code as keyof typeof Language.errorsByCode]
: Language.errorsByCode.default,
}
}, {})
}
}
8 changes: 8 additions & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export interface ApiError {
message: string
errors?: Array<{
field: string
code: string
}>
}

/**
* `BuildInfoResponse` must be kept in sync with the go struct in buildinfo.go.
*/
Expand Down
15 changes: 12 additions & 3 deletions site/src/components/Preferences/AccountForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import TextField from "@material-ui/core/TextField"
import { FormikContextType, useFormik } from "formik"
import React from "react"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React, { useEffect } from "react"
import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../Form"
import { FormStack } from "../Form/FormStack"
Expand All @@ -27,19 +27,28 @@ const validationSchema = Yup.object({
username: Yup.string().trim(),
})

export type AccountFormErrors = FormikErrors<AccountFormValues>
export interface AccountFormProps {
isLoading: boolean
initialValues: AccountFormValues
onSubmit: (values: AccountFormValues) => Promise<void>
errors?: AccountFormErrors
}

export const AccountForm: React.FC<AccountFormProps> = ({ isLoading, onSubmit, initialValues }) => {
export const AccountForm: React.FC<AccountFormProps> = ({ isLoading, onSubmit, initialValues, errors }) => {
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
initialValues,
validationSchema,
onSubmit,
})

// Sync errors from parent
useEffect(() => {
if (errors) {
form.setErrors(errors)
}
}, [errors, form])

return (
<>
<form onSubmit={form.handleSubmit}>
Expand Down
85 changes: 64 additions & 21 deletions site/src/pages/preferences/account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,84 @@ import { renderWithAuth } from "../../test_helpers/render"
import * as AuthXService from "../../xServices/auth/authXService"
import { PreferencesAccountPage } from "./account"

const renderPage = () => {
return renderWithAuth(
<>
<PreferencesAccountPage />
<GlobalSnackbar />
</>,
)
}

const newData = {
name: "User",
email: "user@coder.com",
username: "user",
}

const fillTheForm = async () => {
await waitFor(() => screen.findByLabelText("Name"))
fireEvent.change(screen.getByLabelText("Name"), { target: { value: newData.name } })
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } })
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } })
fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences))
}

describe("PreferencesAccountPage", () => {
afterEach(() => {
jest.clearAllMocks()
})

describe("when it is a success", () => {
it("shows the success message", async () => {
const newUserProfile = {
name: "User",
email: "user@coder.com",
username: "user",
}

jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({
id: userId,
...data,
created_at: new Date().toString(),
}),
)

const { user } = renderWithAuth(
<>
<PreferencesAccountPage />
<GlobalSnackbar />
</>,
)

// Wait for the form to load
await waitFor(() => screen.findByLabelText("Name"))
fireEvent.change(screen.getByLabelText("Name"), { target: { value: newUserProfile.name } })
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newUserProfile.email } })
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newUserProfile.username } })
fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences))
const { user } = renderPage()
await fillTheForm()

const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate)
expect(successMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newUserProfile)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})

describe("when the email is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
isAxiosError: true,
response: { data: { errors: [{ code: "exists", field: "email" }] } },
})

const { user } = renderPage()
await fillTheForm()

const errorMessage = await screen.findByText(API.Language.errorsByCode.exists)
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})

describe("when the username is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
isAxiosError: true,
response: { data: { errors: [{ code: "exists", field: "username" }] } },
})

const { user } = renderPage()
await fillTheForm()

const errorMessage = await screen.findByText(API.Language.errorsByCode.exists)
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
})
3 changes: 3 additions & 0 deletions site/src/pages/preferences/account.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { getFormErrorsFromApiError } from "../../api"
import { AccountForm } from "../../components/Preferences/AccountForm"
import { Section } from "../../components/Section"
import { XServiceContext } from "../../xServices/StateContext"
Expand All @@ -13,6 +14,7 @@ export const PreferencesAccountPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { me } = authState.context
const formErrors = getFormErrorsFromApiError(authState.context.updateProfileError)

if (!me) {
throw new Error("No current user found")
Expand All @@ -22,6 +24,7 @@ export const PreferencesAccountPage: React.FC = () => {
<>
<Section title={Language.title} description={Language.description}>
<AccountForm
errors={formErrors}
isLoading={authState.matches("signedIn.profile.updatingProfile")}
initialValues={{ name: me.name ?? "", username: me.username, email: me.email }}
onSubmit={async (data) => {
Expand Down