Skip to content

feat: add the preferences/account page #999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Apr 15, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion site/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ beforeAll(() =>

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers())
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})

// Clean up after the tests are finished.
afterAll(() => server.close())
Expand Down
38 changes: 38 additions & 0 deletions site/src/api/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { isApiError, mapApiErrorToFieldErrors } from "./errors"

describe("isApiError", () => {
it("returns true when the object is an API Error", () => {
expect(
isApiError({
isAxiosError: true,
response: {
data: {
message: "Invalid entry",
errors: [{ detail: "Username is already in use", field: "username" }],
},
},
}),
).toBe(true)
})

it("returns false when the object is Error", () => {
expect(isApiError(new Error())).toBe(false)
})

it("returns false when the object is undefined", () => {
expect(isApiError(undefined)).toBe(false)
})
})

describe("mapApiErrorToFieldErrors", () => {
it("returns correct field errors", () => {
expect(
mapApiErrorToFieldErrors({
message: "Invalid entry",
errors: [{ detail: "Username is already in use", field: "username" }],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love having these tests because it helps me see what the data structure looks like :)

}),
).toEqual({
username: "Username is already in use",
})
})
})
46 changes: 46 additions & 0 deletions site/src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import axios, { AxiosError, AxiosResponse } from "axios"

export const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
}

interface FieldError {
field: string
detail: string
}

type FieldErrors = Record<FieldError["field"], FieldError["detail"]>

export interface ApiErrorResponse {
message: string
errors?: FieldError[]
}

export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export const isApiError = (err: any): err is ApiError => {
if (axios.isAxiosError(err)) {
const response = err.response?.data

return (
typeof response.message === "string" && (typeof response.errors === "undefined" || Array.isArray(response.errors))
)
}

return false
}

export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => {
const result: FieldErrors = {}

if (apiErrorResponse.errors) {
for (const error of apiErrorResponse.errors) {
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode
}
}

return result
}
7 changes: 6 additions & 1 deletion site/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios, { AxiosRequestHeaders } from "axios"
import { mutate } from "swr"
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
import { MockPager, MockUser, MockUser2 } from "../test_helpers/entities"
import * as Types from "./types"

const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
Expand Down Expand Up @@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async (
headers: { ...CONTENT_TYPE_JSON },
})
}

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
}
7 changes: 7 additions & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface UserResponse {
readonly username: string
readonly email: string
readonly created_at: string
readonly name: string
}

/**
Expand Down Expand Up @@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
export interface WorkspaceAutostopRequest {
schedule: string
}

export interface UpdateProfileRequest {
readonly username: string
readonly email: string
readonly name: string
}
4 changes: 2 additions & 2 deletions site/src/components/Form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ interface FormHelpers {
helperText?: string
}

export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string, error?: string): FormHelpers => {
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
const touched = getIn(form.touched, name)
const errors = getIn(form.errors, name)
const errors = error ?? getIn(form.errors, name)
return {
...form.getFieldProps(name),
id: name,
Expand Down
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
93 changes: 93 additions & 0 deletions site/src/components/Preferences/AccountForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../Form"
import { Stack } from "../Stack/Stack"
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) => void
formErrors?: AccountFormErrors
error?: string
}

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

return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFormHelpers<AccountFormValues>(form, "name")}
autoFocus
autoComplete="name"
fullWidth
label={Language.nameLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>

{error && <FormHelperText error>{error}</FormHelperText>}

<div>
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePreferences}
</LoadingButton>
</div>
</Stack>
</form>
</>
)
}
22 changes: 22 additions & 0 deletions site/src/components/Stack/Stack.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import TextField from "@material-ui/core/TextField"
import { Story } from "@storybook/react"
import React from "react"
import { Stack, StackProps } from "./Stack"

export default {
title: "Components/Stack",
component: Stack,
}

const Template: Story<StackProps> = (args: StackProps) => (
<Stack {...args}>
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
</Stack>
)

export const Example = Template.bind({})
Example.args = {
spacing: 2,
}
19 changes: 19 additions & 0 deletions site/src/components/Stack/Stack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"

export interface StackProps {
spacing?: number
}

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

export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
const styles = useStyles({ spacing })
return <div className={styles.stack}>{children}</div>
}
106 changes: 106 additions & 0 deletions site/src/pages/preferences/account.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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"
import * as AuthXService from "../../xServices/auth/authXService"
import { Language, PreferencesAccountPage } from "./account"

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

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

const fillAndSubmitForm = 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", () => {
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 fillAndSubmitForm()

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: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] },
},
})

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

const errorMessage = await screen.findByText("Email is already in use")
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: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] },
},
})

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

const errorMessage = await screen.findByText("Username is already in use")
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})

describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
data: "unknown error",
})

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

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