Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
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
34 changes: 34 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 @@ -73,3 +80,30 @@ export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
const response = await axios.get("/api/v2/buildinfo")
return response.data
}

export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.UserResponse> => {
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,
}
}, {})
}
}
15 changes: 15 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 All @@ -15,6 +23,7 @@ export interface UserResponse {
readonly username: string
readonly email: string
readonly created_at: string
readonly name?: string
}

/**
Expand Down Expand Up @@ -74,3 +83,9 @@ export interface UserAgent {
readonly ip_address: string
readonly os: string
}

export interface UpdateProfileRequest {
readonly username: string
readonly email: string
readonly name?: string
}
16 changes: 16 additions & 0 deletions site/src/components/Form/FormStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"

const useStyles = makeStyles((theme) => ({
stack: {
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
},
}))

export const FormStack: React.FC = ({ children }) => {
const styles = useStyles()

return <div className={styles.stack}>{children}</div>
}
2 changes: 1 addition & 1 deletion site/src/components/Page/RequireAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
const location = useLocation()
const redirectTo = embedRedirect(location.pathname)

if (authState.matches("signedOut") || !authState.context.me) {
if (authState.matches("signedOut")) {
return <Navigate to={redirectTo} />
} else if (authState.hasTag("loading")) {
return <FullScreenLoader />
Expand Down
90 changes: 90 additions & 0 deletions site/src/components/Preferences/AccountForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import TextField from "@material-ui/core/TextField"
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"
import { LoadingButton } from "./../Button"

interface AccountFormValues {
name: string
email: string
username: string
}

export const Language = {
nameLabel: "Name",
usernameLabel: "Username",
emailLabel: "Email",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
updatePreferences: "Update preferences",
}

const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
name: Yup.string().optional(),
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, 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}>
<FormStack>
<TextField
{...getFormHelpers<AccountFormValues>(form, "name")}
autoFocus
autoComplete="name"
fullWidth
label={Language.nameLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "email")}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "username")}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>

<div>
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePreferences}
</LoadingButton>
</div>
</FormStack>
</form>
</>
)
}
90 changes: 90 additions & 0 deletions site/src/pages/preferences/account.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import * as AccountForm from "../../components/Preferences/AccountForm"
import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar"
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 () => {
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({
id: userId,
...data,
created_at: new Date().toString(),
}),
)
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, 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)
})
})
})
35 changes: 32 additions & 3 deletions site/src/pages/preferences/account.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import React from "react"
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"

const Language = {
title: "Account",
description: "Update your display name, email, profile picture, and dotfiles preferences.",
description: "Update your display name, email and username.",
}

export const PreferencesAccountPage: React.FC = () => {
return <Section title={Language.title} description={Language.description} />
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")
}

return (
<>
<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) => {
authSend({
type: "UPDATE_PROFILE",
data,
})
}}
/>
</Section>
</>
)
}
25 changes: 25 additions & 0 deletions site/src/test_helpers/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render, RenderResult } from "@testing-library/react"
import React from "react"
import { MemoryRouter as Router, Route, Routes } from "react-router-dom"
import { RequireAuth } from "../components/Page/RequireAuth"
import { XServiceProvider } from "../xServices/StateContext"
import { MockUser } from "./entities"

type RenderWithAuthResult = RenderResult & { user: typeof MockUser }

export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult {
const renderResult = render(
<Router initialEntries={[route]}>
<XServiceProvider>
<Routes>
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
</Routes>
</XServiceProvider>
</Router>,
)

return {
user: MockUser,
...renderResult,
}
}
Loading