- Notifications
You must be signed in to change notification settings - Fork928
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
19579eb
9ab1f96
a23cc88
f9f1c5a
1bbaf8a
fa1d0e6
1278ed6
7ccf811
2ae0987
4595186
fa91276
fc01ff8
f3fedd0
807d4e9
fa580c7
3d76331
37bc235
17a0b16
0e8ac63
12058f8
e489210
8098628
1f23e30
a0588d1
b3159d0
e07d717
4d7da77
8d63848
a11ff10
bde7c15
684b902
bbf2152
7f32600
eb65490
59bac76
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
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" }], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
}) | ||
}) | ||
}) | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. |
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> } | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
// 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 => { | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
const result: FieldErrors = {} | ||
if (apiErrorResponse.errors) { | ||
for (const error of apiErrorResponse.errors) { | ||
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode | ||
} | ||
} | ||
return result | ||
} |
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/entities" | ||
import * as Types from "./types" | ||
const CONTENT_TYPE_JSON: AxiosRequestHeaders = { | ||
@@ -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 | ||
} | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => { | ||
const location = useLocation() | ||
const redirectTo = embedRedirect(location.pathname) | ||
if (authState.matches("signedOut")) { | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
return <Navigate to={redirectTo} /> | ||
} else if (authState.hasTag("loading")) { | ||
return <FullScreenLoader /> | ||
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> | ||
</> | ||
) | ||
} |
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, | ||
} |
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> | ||
} |
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 } }) | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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(), | ||
}), | ||
) | ||
BrunoQuaresma marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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) | ||
}) | ||
}) | ||
}) |
Uh oh!
There was an error while loading.Please reload this page.