From 19579eb48fcedac78f271923f55d5932f199d1da Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 12 Apr 2022 14:15:40 +0000 Subject: [PATCH 01/28] feat: Add account form --- .../components/Preferences/AccountForm.tsx | 101 ++++++++++++++++++ site/src/pages/preferences/account.tsx | 17 ++- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 site/src/components/Preferences/AccountForm.tsx diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx new file mode 100644 index 0000000000000..d9e62c2eece46 --- /dev/null +++ b/site/src/components/Preferences/AccountForm.tsx @@ -0,0 +1,101 @@ +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import { FormikContextType, useFormik } from "formik" +import React from "react" +import * as Yup from "yup" +import { getFormHelpers, onChangeTrimmed } from "../Form" +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().trim().optional(), + username: Yup.string().trim(), +}) + +const useStyles = makeStyles((theme) => ({ + loginBtnWrapper: { + marginTop: theme.spacing(6), + borderTop: `1px solid ${theme.palette.action.disabled}`, + paddingTop: theme.spacing(3), + }, + loginTextField: { + marginTop: theme.spacing(2), + }, + submitBtn: { + marginTop: theme.spacing(2), + }, +})) + +export interface AccountFormProps { + isLoading: boolean + initialValues: AccountFormValues + onSubmit: (values: AccountFormValues) => Promise +} + +export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues }) => { + const styles = useStyles() + + const form: FormikContextType = useFormik({ + initialValues, + validationSchema, + onSubmit, + }) + + return ( + <> +
+ (form, "name")} + onChange={onChangeTrimmed(form)} + autoFocus + autoComplete="name" + className={styles.loginTextField} + fullWidth + label={Language.nameLabel} + variant="outlined" + /> + (form, "email")} + onChange={onChangeTrimmed(form)} + autoFocus + autoComplete="email" + className={styles.loginTextField} + fullWidth + label={Language.emailLabel} + variant="outlined" + /> + (form, "username")} + onChange={onChangeTrimmed(form)} + autoFocus + autoComplete="username" + className={styles.loginTextField} + fullWidth + label={Language.usernameLabel} + variant="outlined" + /> + +
+ + {isLoading ? "" : Language.updatePreferences} + +
+ + + ) +} diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 20cd380c5adc9..b5691424ebc39 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,11 +1,24 @@ import React from "react" +import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" 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
+ return ( + <> +
+ { + console.info(values) + }} + /> +
+ + ) } From f9f1c5a7b8b6ac6dd43cd9aa13fc97919404a449 Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 13 Apr 2022 12:41:15 +0000 Subject: [PATCH 02/28] feat: Add account form --- site/src/api/index.ts | 5 ++ site/src/api/types.ts | 7 ++ .../components/Preferences/AccountForm.tsx | 4 +- site/src/pages/preferences/account.tsx | 23 ++++-- site/src/xServices/auth/authXService.ts | 78 +++++++++++++++---- 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 764e32489f438..32aba23016cfc 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -73,3 +73,8 @@ export const getBuildInfo = async (): Promise => { const response = await axios.get("/api/v2/buildinfo") return response.data } + +export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/profile`, data) + return response.data +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 04f5192fd5279..ce034e64ac268 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -15,6 +15,7 @@ export interface UserResponse { readonly username: string readonly email: string readonly created_at: string + readonly name?: string } /** @@ -74,3 +75,9 @@ export interface UserAgent { readonly ip_address: string readonly os: string } + +export interface UpdateProfileRequest { + readonly username: string + readonly email: string + readonly name?: string +} diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index d9e62c2eece46..8caaffea2854a 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -72,7 +72,6 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i (form, "email")} onChange={onChangeTrimmed(form)} - autoFocus autoComplete="email" className={styles.loginTextField} fullWidth @@ -82,7 +81,6 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i (form, "username")} onChange={onChangeTrimmed(form)} - autoFocus autoComplete="username" className={styles.loginTextField} fullWidth @@ -91,7 +89,7 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i />
- + {isLoading ? "" : Language.updatePreferences}
diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index b5691424ebc39..8a60476b7037d 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,6 +1,8 @@ -import React from "react" +import { useActor } from "@xstate/react" +import React, { useContext } from "react" import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" +import { XServiceContext } from "../../xServices/StateContext" const Language = { title: "Account", @@ -8,14 +10,25 @@ const Language = { } export const PreferencesAccountPage: React.FC = () => { + const xServices = useContext(XServiceContext) + const [authState, authSend] = useActor(xServices.authXService) + const { me } = authState.context + + if (!me) { + throw new Error("No current user found") + } + return ( <>
{ - console.info(values) + isLoading={authState.matches("signedIn.profile.updatingProfile")} + initialValues={{ name: me.name ?? "", username: me.username, email: me.email }} + onSubmit={async (data) => { + authSend({ + type: "UPDATE_PROFILE", + data, + }) }} />
diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index ea74bea36d4d0..f9f901abbb941 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -5,15 +5,20 @@ import * as Types from "../../api/types" export interface AuthContext { getUserError?: Error | unknown authError?: Error | unknown + updateProfileError?: Error | unknown me?: Types.UserResponse } -export type AuthEvent = { type: "SIGN_OUT" } | { type: "SIGN_IN"; email: string; password: string } +export type AuthEvent = + | { type: "SIGN_OUT" } + | { type: "SIGN_IN"; email: string; password: string } + | { type: "UPDATE_PROFILE"; data: Types.UpdateProfileRequest } export const authMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QFdZgE4GUAuBDbYAdLAJZQB2kA8stgMSYCSA4gHID6jrioADgPalsJfuR4gAHogDM0gJyEATAHYAbNIAscxdunKArKo0AaEAE9EG+YX0BGABz3FD6atsbbc5QF9vp1Bg4+ESkFCTkUIzkdBCiROEAbvwA1iFk5FHiAkIiYkiSiHIADITSOvbSRRqq2m7StqYWCIr2+ja2tvVV+vqaRVW+-mhYeATE6eGR0Rjo-OiEvAA2+ABmcwC24xSZ+dkkwqLiUghlyoRF+opayq4X+sqK+o2Il6qEcq5y+hrfjt+qgxAARGwUIMGwwgiAFVhjE4oREikiOCALJgLKCfa5I4yIyEZTFZQaezFK5lPTPBC2IqKUqKC4EjqqKrFOSA4FBMbgyFQGEYOgzOYLZbYNboTao9G7TEHPKgY7SewlTQ3dRyVQ6GpVSmKMqEIz2ZRKjrKIqqJzs4actIUSBRBgsDhUKEAFQxOUO+WOhvshA0ahualsqnu8kpthUhGplVUIa+rUq9ktgVGNvIkxo9FilAR5CSqS25Ez7qxnvliCMGlK1Tk6vN5p+T3ML0Ub2U7iKXmcGg8tmTILGoXTEUzAvQs3mS1WG0LxelHrlBQQIbasc7aiKtnbV3sOpaUZXBjkwd1A0B5H4EDg4g5qcL1FoJdlOIQdlpH2qhmJzJ+DWbCB7WxSmUIl2wJM0CSTPwgStO8h0mHY+BlbEvReICaSMbQflkU0fkpHtfS3MC3C+aR9ENfR+2tMEwAhSY+XQJ8UPLACziVS5TXKAxPDI8MaSjIojSqPQezNRUqLg9I7UXPZn1Q5dqn1CMNEEi4HAecNIyVGoIweVQDH0tloNvUF4JHR951LRdvQcc4PCcAx12-fDOnOeRTU7C4SXsPtjNg4ImLLJddUIdiVBpOQKJ4psmh0ASQOPRUiI8RRfF8IA */ + /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogCMABgCcAFkLyZAVilS5GgEwAOAMwA2ADQgAnogDsa5YcOXD2y7oWH92-QF9PptFlz4RKQUJORQDOS0ECJEoQBufADWQWTkEWL8gsKiSBKI7kraqpZSuh7aUvpy6vqmFghlcoT6qgb6GsUlctrevhg4eATEqaHhkWAAThN8E4Q8ADb4AGYzALbDFOm5mSRCImKSCLKKynJqGlpSekZ1iIaltvaOzq4FvSB+A4GEMOhCYQBVWCTKIxQjxJJEX4AWTAGQEu2yB0QrikhG0Mn0+l0ulUWJkhlatXMKKqynkCn0lOscnsUnenwCQ1+-ygQJBk2mswWyzWPzA6Fh8Ky+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmRFQPAg+FGAAVLdawKDKODyAlkoRGYMTZQIOaeK6bYQ7Q7WS6+FabQgIQBjR0iADaMgAusLEaLQIcNJY1YpZB5pQpJVJLMqMbpCKVulVCY5aZXDf4AxsgyGw7b7Y6wjG4+7OTM5ot0CsJut-d9gl3yBbY26I33oz2E96+Mm9uR01ntgid8iGlpCLpLNoFJXpYXHLplbr9IRi4oZDJdHJLIpim2vkM52aC6hkuNq0ACToACIAIIACoAKJsE6ABKVAAGIMAAMnB2ZHmKiDnjI6oOG4ihVM4yqfuiVS6hqEqFooBo+B8RodgBwaRIwrBsFQAIwThSJ4UcWLorSWKqDIlhGJWlhViSCCUaWNGOE4qiKHIv7Gp2ow0OgHqxJuvpzjp-G5nkCDuE+F5yLohiUqU2oeMqOq1moH4XrZL5eExM7-iMYQ6bQI7cuOk7rEZGAmTkeaIEUaruB+CjqFcRL6LJ9RFIRqUKFexQZQYEreEx5B8BAcBiD5gbUBFB4ilFZnqIYhBuNSajYoS1nKoWT4KAYqkeO0GKGOp3ksbOfljJFx5XPKdZ4hJ0qfu0ypDdoZ4ahe4klLiegaR2LKjOyEyTYJ5bKroGpnkSWiSbimiqLtY2muxi5DuCEDhsVcFTDMx3RUc0lnoWb4KJJhJag+F1ZZSqiDdZGKWA9vlPd2IGxO9RBBb9ZkFpYgPiTIINGOJSpya4jXiYo2WfvqEkSYjlXPcBr0kOjWP5lIeJ48DoPE8qMrNJY2VXKUzy6PTnaAS9y6Rv2UCDm6bP4QYhD0ZUqUltiUjKndTUfqWTaSvcCMje2j3zlLNqKw0rhEXY1FkfeclXFRN42Vqah4oYYsm3+DNbLwh4CX9ZSre0xH25+jv1AAtDNDgEgSqhJ7ZuKEuLc7adVAe1ce7iEFTl6FkUOIyFIChOeJyitGWpQEho9I+8aVu1gXIMw60uil+XcnR2rKvyvKydnBzrSFZ4QA */ createMachine( { + context: { me: undefined, getUserError: undefined, authError: undefined, updateProfileError: undefined }, tsTypes: {} as import("./authXService.typegen").Typegen0, schema: { context: {} as AuthContext, @@ -25,20 +30,18 @@ export const authMachine = signIn: { data: Types.LoginResponse } + updateProfile: { + data: Types.UserResponse + } }, }, - context: { - me: undefined, - getUserError: undefined, - authError: undefined, - }, id: "authState", initial: "gettingUser", states: { signedOut: { on: { SIGN_IN: { - target: "#authState.signingIn", + target: "signingIn", }, }, }, @@ -48,14 +51,14 @@ export const authMachine = id: "signIn", onDone: [ { - target: "#authState.gettingUser", actions: "clearAuthError", + target: "gettingUser", }, ], onError: [ { actions: "assignAuthError", - target: "#authState.signedOut", + target: "signedOut", }, ], }, @@ -68,22 +71,57 @@ export const authMachine = onDone: [ { actions: ["assignMe", "clearGetUserError"], - target: "#authState.signedIn", + target: "signedIn", }, ], onError: [ { actions: "assignGetUserError", - target: "#authState.signedOut", + target: "signedOut", }, ], }, tags: "loading", }, signedIn: { + type: "parallel", + states: { + profile: { + states: { + idle: { + states: { + noError: {}, + error: {}, + }, + }, + updatingProfile: { + invoke: { + src: "updateProfile", + onDone: [ + { + actions: "assignMe", + target: "#authState.signedIn.profile.idle.noError", + }, + ], + onError: [ + { + actions: "assignUpdateProfileError", + target: "#authState.signedIn.profile.idle.error", + }, + ], + }, + }, + }, + on: { + UPDATE_PROFILE: { + target: ".updatingProfile", + }, + }, + }, + }, on: { SIGN_OUT: { - target: "#authState.signingOut", + target: "signingOut", }, }, }, @@ -94,13 +132,13 @@ export const authMachine = onDone: [ { actions: ["unassignMe", "clearAuthError"], - target: "#authState.signedOut", + target: "signedOut", }, ], onError: [ { actions: "assignAuthError", - target: "#authState.signedIn", + target: "signedIn", }, ], }, @@ -115,6 +153,13 @@ export const authMachine = }, signOut: API.logout, getMe: API.getUser, + updateProfile: async (context, event) => { + if (!context.me) { + throw new Error("No current user found") + } + + return API.updateProfile(context.me.id, event.data) + }, }, actions: { assignMe: assign({ @@ -138,6 +183,9 @@ export const authMachine = ...context, authError: undefined, })), + assignUpdateProfileError: assign({ + updateProfileError: (_, event) => event.data, + }), }, }, ) From fa1d0e6b181e8acf100f82550cb3c3915a7915cf Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 13 Apr 2022 19:27:12 +0000 Subject: [PATCH 03/28] feat: show notification when preferences are updated --- site/src/components/Form/FormStack.tsx | 16 ++++ .../components/Preferences/AccountForm.tsx | 84 ++++++++----------- site/src/xServices/auth/authXService.ts | 9 +- 3 files changed, 57 insertions(+), 52 deletions(-) create mode 100644 site/src/components/Form/FormStack.tsx diff --git a/site/src/components/Form/FormStack.tsx b/site/src/components/Form/FormStack.tsx new file mode 100644 index 0000000000000..42dfd85a431e0 --- /dev/null +++ b/site/src/components/Form/FormStack.tsx @@ -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
{children}
+} diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 8caaffea2854a..392b030e2fc34 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -1,9 +1,9 @@ -import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import { FormikContextType, useFormik } from "formik" import React from "react" import * as Yup from "yup" import { getFormHelpers, onChangeTrimmed } from "../Form" +import { FormStack } from "../Form/FormStack" import { LoadingButton } from "./../Button" interface AccountFormValues { @@ -23,24 +23,10 @@ export const Language = { const validationSchema = Yup.object({ email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired), - name: Yup.string().trim().optional(), + name: Yup.string().optional(), username: Yup.string().trim(), }) -const useStyles = makeStyles((theme) => ({ - loginBtnWrapper: { - marginTop: theme.spacing(6), - borderTop: `1px solid ${theme.palette.action.disabled}`, - paddingTop: theme.spacing(3), - }, - loginTextField: { - marginTop: theme.spacing(2), - }, - submitBtn: { - marginTop: theme.spacing(2), - }, -})) - export interface AccountFormProps { isLoading: boolean initialValues: AccountFormValues @@ -48,8 +34,6 @@ export interface AccountFormProps { } export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues }) => { - const styles = useStyles() - const form: FormikContextType = useFormik({ initialValues, validationSchema, @@ -59,40 +43,38 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i return ( <>
- (form, "name")} - onChange={onChangeTrimmed(form)} - autoFocus - autoComplete="name" - className={styles.loginTextField} - fullWidth - label={Language.nameLabel} - variant="outlined" - /> - (form, "email")} - onChange={onChangeTrimmed(form)} - autoComplete="email" - className={styles.loginTextField} - fullWidth - label={Language.emailLabel} - variant="outlined" - /> - (form, "username")} - onChange={onChangeTrimmed(form)} - autoComplete="username" - className={styles.loginTextField} - fullWidth - label={Language.usernameLabel} - variant="outlined" - /> + + (form, "name")} + autoFocus + autoComplete="name" + fullWidth + label={Language.nameLabel} + variant="outlined" + /> + (form, "email")} + onChange={onChangeTrimmed(form)} + autoComplete="email" + fullWidth + label={Language.emailLabel} + variant="outlined" + /> + (form, "username")} + onChange={onChangeTrimmed(form)} + autoComplete="username" + fullWidth + label={Language.usernameLabel} + variant="outlined" + /> -
- - {isLoading ? "" : Language.updatePreferences} - -
+
+ + {isLoading ? "" : Language.updatePreferences} + +
+
) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index f9f901abbb941..ae070c5b905c7 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -1,7 +1,11 @@ import { assign, createMachine } from "xstate" import * as API from "../../api" import * as Types from "../../api/types" +import { displaySuccess } from "../../components/Snackbar" +const Language = { + successProfileUpdate: "Preferences updated with success!", +} export interface AuthContext { getUserError?: Error | unknown authError?: Error | unknown @@ -99,7 +103,7 @@ export const authMachine = src: "updateProfile", onDone: [ { - actions: "assignMe", + actions: ["assignMe", "notifySuccessProfileUpdate"], target: "#authState.signedIn.profile.idle.noError", }, ], @@ -186,6 +190,9 @@ export const authMachine = assignUpdateProfileError: assign({ updateProfileError: (_, event) => event.data, }), + notifySuccessProfileUpdate: () => { + displaySuccess(Language.successProfileUpdate) + }, }, }, ) From 1278ed6d5574fe6d877c00fa38af4ca2db4dd210 Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 13 Apr 2022 20:36:18 +0000 Subject: [PATCH 04/28] test: account form submission with success --- site/src/components/Page/RequireAuth.tsx | 2 +- site/src/pages/preferences/account.test.tsx | 47 +++++++++++++++++++++ site/src/test_helpers/render.tsx | 25 +++++++++++ site/src/xServices/auth/authXService.ts | 4 +- 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/preferences/account.test.tsx create mode 100644 site/src/test_helpers/render.tsx diff --git a/site/src/components/Page/RequireAuth.tsx b/site/src/components/Page/RequireAuth.tsx index 011d958146018..43343e52a50f7 100644 --- a/site/src/components/Page/RequireAuth.tsx +++ b/site/src/components/Page/RequireAuth.tsx @@ -15,7 +15,7 @@ export const RequireAuth: React.FC = ({ children }) => { const location = useLocation() const redirectTo = embedRedirect(location.pathname) - if (authState.matches("signedOut") || !authState.context.me) { + if (authState.matches("signedOut")) { return } else if (authState.hasTag("loading")) { return diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx new file mode 100644 index 0000000000000..4e0d148db8e69 --- /dev/null +++ b/site/src/pages/preferences/account.test.tsx @@ -0,0 +1,47 @@ +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" + +describe("PreferencesAccountPage", () => { + 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( + <> + + + , + ) + + // Wait for the form to load + await waitFor(() => screen.findByLabelText("Name"), { timeout: 50000 }) + 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 successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate) + expect(successMessage).toBeDefined() + expect(API.updateProfile).toBeCalledTimes(1) + expect(API.updateProfile).toBeCalledWith(user.id, newUserProfile) + }) + }) +}) diff --git a/site/src/test_helpers/render.tsx b/site/src/test_helpers/render.tsx new file mode 100644 index 0000000000000..8800c9f1a1048 --- /dev/null +++ b/site/src/test_helpers/render.tsx @@ -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( + + + + {ui}} /> + + + , + ) + + return { + user: MockUser, + ...renderResult, + } +} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index ae070c5b905c7..db4a2b08500c6 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -3,7 +3,7 @@ import * as API from "../../api" import * as Types from "../../api/types" import { displaySuccess } from "../../components/Snackbar" -const Language = { +export const Language = { successProfileUpdate: "Preferences updated with success!", } export interface AuthContext { @@ -91,8 +91,10 @@ export const authMachine = type: "parallel", states: { profile: { + initial: "idle", states: { idle: { + initial: "noError", states: { noError: {}, error: {}, From 7ccf8115594cf97e911390346fe3d27d94fb0009 Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 13 Apr 2022 20:36:57 +0000 Subject: [PATCH 05/28] chore: remove unecessary timeout --- site/src/pages/preferences/account.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 4e0d148db8e69..e396f06729478 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -32,7 +32,7 @@ describe("PreferencesAccountPage", () => { ) // Wait for the form to load - await waitFor(() => screen.findByLabelText("Name"), { timeout: 50000 }) + 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 } }) From 2ae098749e06a5b44ccccb57652e523214225845 Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 13 Apr 2022 21:55:18 +0000 Subject: [PATCH 06/28] test: add tests --- site/src/api/index.ts | 29 +++++++ site/src/api/types.ts | 8 ++ .../components/Preferences/AccountForm.tsx | 15 +++- site/src/pages/preferences/account.test.tsx | 85 ++++++++++++++----- site/src/pages/preferences/account.tsx | 3 + 5 files changed, 116 insertions(+), 24 deletions(-) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 32aba23016cfc..7ed6a3543f5c0 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -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", } @@ -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 | 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, + } + }, {}) + } +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index ce034e64ac268..cd7394e14b6d8 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -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. */ diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 392b030e2fc34..12364143a6735 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -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" @@ -27,19 +27,28 @@ const validationSchema = Yup.object({ username: Yup.string().trim(), }) +export type AccountFormErrors = FormikErrors export interface AccountFormProps { isLoading: boolean initialValues: AccountFormValues onSubmit: (values: AccountFormValues) => Promise + errors?: AccountFormErrors } -export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues }) => { +export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues, errors }) => { const form: FormikContextType = useFormik({ initialValues, validationSchema, onSubmit, }) + // Sync errors from parent + useEffect(() => { + if (errors) { + form.setErrors(errors) + } + }, [errors, form]) + return ( <>
diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index e396f06729478..ef546a487aaa0 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -7,15 +7,36 @@ import { renderWithAuth } from "../../test_helpers/render" import * as AuthXService from "../../xServices/auth/authXService" import { PreferencesAccountPage } from "./account" +const renderPage = () => { + return renderWithAuth( + <> + + + , + ) +} + +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, @@ -23,25 +44,47 @@ describe("PreferencesAccountPage", () => { created_at: new Date().toString(), }), ) - - const { user } = renderWithAuth( - <> - - - , - ) - - // 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) }) }) }) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 8a60476b7037d..40a6503f8d759 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -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" @@ -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") @@ -22,6 +24,7 @@ export const PreferencesAccountPage: React.FC = () => { <>
{ From 4595186c0bcc05e2abca4a195502c57d3b0061e9 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 09:46:48 -0300 Subject: [PATCH 07/28] style: fix message copy Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com> --- site/src/pages/preferences/account.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 40a6503f8d759..61d9c40ad8c62 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -7,7 +7,7 @@ import { XServiceContext } from "../../xServices/StateContext" const Language = { title: "Account", - description: "Update your display name, email and username.", + description: "Update your display name, email, and username.", } export const PreferencesAccountPage: React.FC = () => { From fa9127635aa32bd9eb6635aecb5c4b13b886e5ce Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 09:47:03 -0300 Subject: [PATCH 08/28] style: improve success message Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com> --- site/src/xServices/auth/authXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index db4a2b08500c6..9c07ee32f8f92 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -4,7 +4,7 @@ import * as Types from "../../api/types" import { displaySuccess } from "../../components/Snackbar" export const Language = { - successProfileUpdate: "Preferences updated with success!", + successProfileUpdate: "Preferences updated successfully!", } export interface AuthContext { getUserError?: Error | unknown From fc01ff8199c4f99fd8ab663fa18857f472d24426 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 10:00:41 -0300 Subject: [PATCH 09/28] refactor: name is not optional Co-authored-by: G r e y --- site/src/api/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index cd7394e14b6d8..0581c4636253f 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -23,7 +23,7 @@ export interface UserResponse { readonly username: string readonly email: string readonly created_at: string - readonly name?: string + readonly name: string } /** From f3fedd0711de83ecd5747fdc99e3acc3ba2db1cf Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 14:06:11 +0000 Subject: [PATCH 10/28] chore: move renderWithAuth to test_hepers/index.tsx --- site/src/pages/preferences/account.test.tsx | 2 +- site/src/test_helpers/index.tsx | 23 ++++++++++++++++++- site/src/test_helpers/render.tsx | 25 --------------------- 3 files changed, 23 insertions(+), 27 deletions(-) delete mode 100644 site/src/test_helpers/render.tsx diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index ef546a487aaa0..2398dd311c78e 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -3,7 +3,7 @@ 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 { renderWithAuth } from "../../test_helpers" import * as AuthXService from "../../xServices/auth/authXService" import { PreferencesAccountPage } from "./account" diff --git a/site/src/test_helpers/index.tsx b/site/src/test_helpers/index.tsx index 4da5fd57587bb..af63f59f8b3a7 100644 --- a/site/src/test_helpers/index.tsx +++ b/site/src/test_helpers/index.tsx @@ -2,9 +2,11 @@ import ThemeProvider from "@material-ui/styles/ThemeProvider" import { render as wrappedRender, RenderResult } from "@testing-library/react" import { createMemoryHistory } from "history" import React from "react" -import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom" +import { MemoryRouter, Route, Routes, unstable_HistoryRouter as HistoryRouter } from "react-router-dom" +import { RequireAuth } from "../components" import { dark } from "../theme" import { XServiceProvider } from "../xServices/StateContext" +import { MockUser } from "./entities" export const history = createMemoryHistory() @@ -22,4 +24,23 @@ export const render = (component: React.ReactElement): RenderResult => { return wrappedRender({component}) } +type RenderWithAuthResult = RenderResult & { user: typeof MockUser } + +export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult { + const renderResult = render( + + + + {ui}} /> + + + , + ) + + return { + user: MockUser, + ...renderResult, + } +} + export * from "./entities" diff --git a/site/src/test_helpers/render.tsx b/site/src/test_helpers/render.tsx deleted file mode 100644 index 8800c9f1a1048..0000000000000 --- a/site/src/test_helpers/render.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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( - - - - {ui}} /> - - - , - ) - - return { - user: MockUser, - ...renderResult, - } -} From 807d4e975d7ab20377021f9c57c2e46374c5e35b Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 14:09:29 +0000 Subject: [PATCH 11/28] chore: move error types and utils to api/errors.ts --- site/src/api/errors.ts | 31 +++++++++++++++++++++ site/src/api/index.ts | 29 ------------------- site/src/pages/preferences/account.test.tsx | 5 ++-- 3 files changed, 34 insertions(+), 31 deletions(-) create mode 100644 site/src/api/errors.ts diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts new file mode 100644 index 0000000000000..948018a0f1bbf --- /dev/null +++ b/site/src/api/errors.ts @@ -0,0 +1,31 @@ +import axios from "axios" +import * as Types from "./types" + +export const Language = { + errorsByCode: { + default: "Invalid value", + exists: "This value is already in use", + }, +} + +const getApiError = (error: unknown): Types.ApiError | undefined => { + if (axios.isAxiosError(error)) { + return error.response?.data + } +} + +export const getFormErrorsFromApiError = (error: unknown): Record | 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, + } + }, {}) + } +} diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 7ed6a3543f5c0..32aba23016cfc 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -2,13 +2,6 @@ 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", } @@ -85,25 +78,3 @@ 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 | 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, - } - }, {}) - } -} diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 2398dd311c78e..ecd7cd3f84c1f 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react" import React from "react" import * as API from "../../api" +import { Language } from "../../api/errors" import * as AccountForm from "../../components/Preferences/AccountForm" import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar" import { renderWithAuth } from "../../test_helpers" @@ -64,7 +65,7 @@ describe("PreferencesAccountPage", () => { const { user } = renderPage() await fillTheForm() - const errorMessage = await screen.findByText(API.Language.errorsByCode.exists) + const errorMessage = await screen.findByText(Language.errorsByCode.exists) expect(errorMessage).toBeDefined() expect(API.updateProfile).toBeCalledTimes(1) expect(API.updateProfile).toBeCalledWith(user.id, newData) @@ -81,7 +82,7 @@ describe("PreferencesAccountPage", () => { const { user } = renderPage() await fillTheForm() - const errorMessage = await screen.findByText(API.Language.errorsByCode.exists) + const errorMessage = await screen.findByText(Language.errorsByCode.exists) expect(errorMessage).toBeDefined() expect(API.updateProfile).toBeCalledTimes(1) expect(API.updateProfile).toBeCalledWith(user.id, newData) From fa580c7f4de43474e867c9614d78d81cfc17807c Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 14:12:17 +0000 Subject: [PATCH 12/28] test: use userEvent --- site/src/pages/preferences/account.test.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index ecd7cd3f84c1f..96025556b71b1 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react" +import { screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import React from "react" import * as API from "../../api" import { Language } from "../../api/errors" @@ -23,12 +24,12 @@ const newData = { username: "user", } -const fillTheForm = async () => { +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)) + await userEvent.type(screen.getByLabelText("Name"), newData.name) + await userEvent.type(screen.getByLabelText("Email"), newData.email) + await userEvent.type(screen.getByLabelText("Username"), newData.username) + await userEvent.click(screen.getByText(AccountForm.Language.updatePreferences)) } describe("PreferencesAccountPage", () => { @@ -46,7 +47,7 @@ describe("PreferencesAccountPage", () => { }), ) const { user } = renderPage() - await fillTheForm() + await fillAndSubmitForm() const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate) expect(successMessage).toBeDefined() @@ -63,7 +64,7 @@ describe("PreferencesAccountPage", () => { }) const { user } = renderPage() - await fillTheForm() + await fillAndSubmitForm() const errorMessage = await screen.findByText(Language.errorsByCode.exists) expect(errorMessage).toBeDefined() @@ -80,7 +81,7 @@ describe("PreferencesAccountPage", () => { }) const { user } = renderPage() - await fillTheForm() + await fillAndSubmitForm() const errorMessage = await screen.findByText(Language.errorsByCode.exists) expect(errorMessage).toBeDefined() From 3d76331248157e02fa29436f3ae6d2717140444a Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 14:13:43 +0000 Subject: [PATCH 13/28] fix: remove async from onSubmit --- site/src/components/Preferences/AccountForm.tsx | 2 +- site/src/pages/preferences/account.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 12364143a6735..9d55a7fa8a113 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -31,7 +31,7 @@ export type AccountFormErrors = FormikErrors export interface AccountFormProps { isLoading: boolean initialValues: AccountFormValues - onSubmit: (values: AccountFormValues) => Promise + onSubmit: (values: AccountFormValues) => void errors?: AccountFormErrors } diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 40a6503f8d759..f75f39f511cbd 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,6 +1,6 @@ import { useActor } from "@xstate/react" import React, { useContext } from "react" -import { getFormErrorsFromApiError } from "../../api" +import { getFormErrorsFromApiError } from "../../api/errors" import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" import { XServiceContext } from "../../xServices/StateContext" @@ -27,7 +27,7 @@ export const PreferencesAccountPage: React.FC = () => { errors={formErrors} isLoading={authState.matches("signedIn.profile.updatingProfile")} initialValues={{ name: me.name ?? "", username: me.username, email: me.email }} - onSubmit={async (data) => { + onSubmit={(data) => { authSend({ type: "UPDATE_PROFILE", data, From 37bc235889eac9353218089e24c9ed7fdc21c20c Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 14:23:55 +0000 Subject: [PATCH 14/28] refactor: improve error types --- site/src/api/errors.ts | 43 +++++++++++++++----------- site/src/api/types.ts | 8 ----- site/src/pages/preferences/account.tsx | 5 +-- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 948018a0f1bbf..57ede5e684471 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -1,31 +1,38 @@ import axios from "axios" -import * as Types from "./types" export const Language = { errorsByCode: { - default: "Invalid value", + defaultErrorCode: "Invalid value", exists: "This value is already in use", }, } -const getApiError = (error: unknown): Types.ApiError | undefined => { - if (axios.isAxiosError(error)) { - return error.response?.data +interface FieldError { + field: string + code: string +} + +type FieldErrors = Record + +export interface ApiError { + message: string + errors?: FieldError[] +} + +export const mapApiErrorToFieldErrors = (apiError: ApiError): FieldErrors => { + const result: FieldErrors = {} + + if (apiError.errors) { + for (const error of apiError.errors) { + result[error.field] = error.code || Language.errorsByCode.defaultErrorCode + } } + + return result } -export const getFormErrorsFromApiError = (error: unknown): Record | 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, - } - }, {}) +export const getApiError = (error: unknown): ApiError | undefined => { + if (axios.isAxiosError(error)) { + return error.response?.data } } diff --git a/site/src/api/types.ts b/site/src/api/types.ts index cd7394e14b6d8..ce034e64ac268 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -1,11 +1,3 @@ -export interface ApiError { - message: string - errors?: Array<{ - field: string - code: string - }> -} - /** * `BuildInfoResponse` must be kept in sync with the go struct in buildinfo.go. */ diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index f75f39f511cbd..10a5196929172 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,6 +1,6 @@ import { useActor } from "@xstate/react" import React, { useContext } from "react" -import { getFormErrorsFromApiError } from "../../api/errors" +import { getApiError, mapApiErrorToFieldErrors } from "../../api/errors" import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" import { XServiceContext } from "../../xServices/StateContext" @@ -14,7 +14,8 @@ 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) + const apiError = getApiError(authState.context.updateProfileError) + const formErrors = apiError ? mapApiErrorToFieldErrors(apiError) : undefined if (!me) { throw new Error("No current user found") From 12058f8955d661a10a330a4f708ed7cfda1c3faf Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 17:14:32 +0000 Subject: [PATCH 15/28] refactor: api errors --- site/src/api/errors.test.ts | 38 +++++++++++++++++++++ site/src/api/errors.ts | 35 +++++++++++++------ site/src/api/types.ts | 2 +- site/src/pages/preferences/account.test.tsx | 9 +++-- site/src/pages/preferences/account.tsx | 9 +++-- site/src/test_helpers/entities.ts | 1 + site/src/test_helpers/index.tsx | 2 +- 7 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 site/src/api/errors.test.ts diff --git a/site/src/api/errors.test.ts b/site/src/api/errors.test.ts new file mode 100644 index 0000000000000..4037f55dfeebc --- /dev/null +++ b/site/src/api/errors.test.ts @@ -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" }], + }), + ).toEqual({ + username: "Username is already in use", + }) + }) +}) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 57ede5e684471..dcc5b4ae18fdd 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -1,38 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import axios from "axios" export const Language = { errorsByCode: { defaultErrorCode: "Invalid value", - exists: "This value is already in use", }, } interface FieldError { field: string - code: string + detail: string } -type FieldErrors = Record +type FieldErrors = Record export interface ApiError { message: string errors?: FieldError[] } +const unwrapAxiosError = (obj: unknown): unknown => { + if (axios.isAxiosError(obj)) { + return obj.response?.data + } else { + return obj + } +} + +export const isApiError = (err: any): err is ApiError => { + const maybeApiError = unwrapAxiosError(err) as Partial | undefined + + if (!maybeApiError || maybeApiError instanceof Error) { + return false + } else if (typeof maybeApiError.message === "string") { + return typeof maybeApiError.errors === "undefined" || Array.isArray(maybeApiError.errors) + } else { + return false + } +} + export const mapApiErrorToFieldErrors = (apiError: ApiError): FieldErrors => { const result: FieldErrors = {} if (apiError.errors) { for (const error of apiError.errors) { - result[error.field] = error.code || Language.errorsByCode.defaultErrorCode + result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode } } return result } - -export const getApiError = (error: unknown): ApiError | undefined => { - if (axios.isAxiosError(error)) { - return error.response?.data - } -} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 374a9f08e7211..53845a2a6cd96 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -91,5 +91,5 @@ export interface WorkspaceAutostopRequest { export interface UpdateProfileRequest { readonly username: string readonly email: string - readonly name?: string + readonly name: string } diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 96025556b71b1..73684b5838377 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -2,7 +2,6 @@ import { screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import React from "react" import * as API from "../../api" -import { Language } from "../../api/errors" import * as AccountForm from "../../components/Preferences/AccountForm" import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar" import { renderWithAuth } from "../../test_helpers" @@ -60,13 +59,13 @@ describe("PreferencesAccountPage", () => { it("shows an error", async () => { jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ isAxiosError: true, - response: { data: { errors: [{ code: "exists", field: "email" }] } }, + response: { data: { errors: [{ detail: "Email is already in use", field: "email" }] } }, }) const { user } = renderPage() await fillAndSubmitForm() - const errorMessage = await screen.findByText(Language.errorsByCode.exists) + 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) @@ -77,13 +76,13 @@ describe("PreferencesAccountPage", () => { it("shows an error", async () => { jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ isAxiosError: true, - response: { data: { errors: [{ code: "exists", field: "username" }] } }, + response: { data: { errors: [{ detail: "Username is already in use", field: "username" }] } }, }) const { user } = renderPage() await fillAndSubmitForm() - const errorMessage = await screen.findByText(Language.errorsByCode.exists) + 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) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 4bf9b0bead47b..55bbdde29eed9 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -1,6 +1,6 @@ import { useActor } from "@xstate/react" import React, { useContext } from "react" -import { getApiError, mapApiErrorToFieldErrors } from "../../api/errors" +import { isApiError, mapApiErrorToFieldErrors } from "../../api/errors" import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" import { XServiceContext } from "../../xServices/StateContext" @@ -13,9 +13,8 @@ const Language = { export const PreferencesAccountPage: React.FC = () => { const xServices = useContext(XServiceContext) const [authState, authSend] = useActor(xServices.authXService) - const { me } = authState.context - const apiError = getApiError(authState.context.updateProfileError) - const formErrors = apiError ? mapApiErrorToFieldErrors(apiError) : undefined + const { me, updateProfileError } = authState.context + const formErrors = isApiError(updateProfileError) ? mapApiErrorToFieldErrors(updateProfileError) : undefined if (!me) { throw new Error("No current user found") @@ -27,7 +26,7 @@ export const PreferencesAccountPage: React.FC = () => { { authSend({ type: "UPDATE_PROFILE", diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index 661608cef3077..c5348d2cbecae 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -19,6 +19,7 @@ export const MockBuildInfo: BuildInfoResponse = { } export const MockUser: UserResponse = { + name: "Test User", id: "test-user", username: "TestUser", email: "test@coder.com", diff --git a/site/src/test_helpers/index.tsx b/site/src/test_helpers/index.tsx index af63f59f8b3a7..419484c9c3ea0 100644 --- a/site/src/test_helpers/index.tsx +++ b/site/src/test_helpers/index.tsx @@ -27,7 +27,7 @@ export const render = (component: React.ReactElement): RenderResult => { type RenderWithAuthResult = RenderResult & { user: typeof MockUser } export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult { - const renderResult = render( + const renderResult = wrappedRender( From e489210735120e33ca09d598ee9df88ddf550b74 Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 17:16:29 +0000 Subject: [PATCH 16/28] refactor: move UPDATE_PROFILE to idle state --- site/src/xServices/auth/authXService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 9c07ee32f8f92..6a5743baa7fb0 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -94,6 +94,11 @@ export const authMachine = initial: "idle", states: { idle: { + on: { + UPDATE_PROFILE: { + target: "#authState.signedIn.profile.updatingProfile", + }, + }, initial: "noError", states: { noError: {}, @@ -118,11 +123,6 @@ export const authMachine = }, }, }, - on: { - UPDATE_PROFILE: { - target: ".updatingProfile", - }, - }, }, }, on: { From 80986286b58cea9efd29e109ab98c4088418da0c Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 17:26:16 +0000 Subject: [PATCH 17/28] refactor: change FormStack to Stack and add storybook --- .../components/Preferences/AccountForm.tsx | 6 ++--- site/src/components/Stack/Stack.stories.tsx | 22 +++++++++++++++++++ .../{Form/FormStack.tsx => Stack/Stack.tsx} | 11 ++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 site/src/components/Stack/Stack.stories.tsx rename site/src/components/{Form/FormStack.tsx => Stack/Stack.tsx} (51%) diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 9d55a7fa8a113..55bf7b5d865c9 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -3,7 +3,7 @@ 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 { Stack } from "../Stack/Stack" import { LoadingButton } from "./../Button" interface AccountFormValues { @@ -52,7 +52,7 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i return ( <> - + (form, "name")} autoFocus @@ -83,7 +83,7 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i {isLoading ? "" : Language.updatePreferences} - + ) diff --git a/site/src/components/Stack/Stack.stories.tsx b/site/src/components/Stack/Stack.stories.tsx new file mode 100644 index 0000000000000..b535c6b558b9e --- /dev/null +++ b/site/src/components/Stack/Stack.stories.tsx @@ -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: "Stack/Stack", + component: Stack, +} + +const Template: Story = (args: StackProps) => ( + + + + + +) + +export const Example = Template.bind({}) +Example.args = { + spacing: 2, +} diff --git a/site/src/components/Form/FormStack.tsx b/site/src/components/Stack/Stack.tsx similarity index 51% rename from site/src/components/Form/FormStack.tsx rename to site/src/components/Stack/Stack.tsx index 42dfd85a431e0..ed1015d9815de 100644 --- a/site/src/components/Form/FormStack.tsx +++ b/site/src/components/Stack/Stack.tsx @@ -1,16 +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: theme.spacing(2), + gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing), }, })) -export const FormStack: React.FC = ({ children }) => { - const styles = useStyles() - +export const Stack: React.FC = ({ children, spacing = 2 }) => { + const styles = useStyles({ spacing }) return
{children}
} From 1f23e30e00bda1f5243b32b20ef8b7090c2d633a Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 18:02:24 +0000 Subject: [PATCH 18/28] fix: error handling and tests --- site/src/api/errors.ts | 34 +++++++++------------ site/src/pages/preferences/account.test.tsx | 19 +++++++----- site/src/pages/preferences/account.tsx | 4 ++- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index dcc5b4ae18fdd..159a30613570e 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import axios from "axios" +import axios, { AxiosError, AxiosResponse } from "axios" export const Language = { errorsByCode: { @@ -15,36 +15,30 @@ interface FieldError { type FieldErrors = Record -export interface ApiError { +export interface ApiErrorResponse { message: string errors?: FieldError[] } -const unwrapAxiosError = (obj: unknown): unknown => { - if (axios.isAxiosError(obj)) { - return obj.response?.data - } else { - return obj - } -} +export type ApiError = AxiosError & { response: AxiosResponse } export const isApiError = (err: any): err is ApiError => { - const maybeApiError = unwrapAxiosError(err) as Partial | undefined - - if (!maybeApiError || maybeApiError instanceof Error) { - return false - } else if (typeof maybeApiError.message === "string") { - return typeof maybeApiError.errors === "undefined" || Array.isArray(maybeApiError.errors) - } else { - return false + 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 = (apiError: ApiError): FieldErrors => { +export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => { const result: FieldErrors = {} - if (apiError.errors) { - for (const error of apiError.errors) { + if (apiErrorResponse.errors) { + for (const error of apiErrorResponse.errors) { result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode } } diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 73684b5838377..e105c2d1b90d5 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -1,5 +1,4 @@ -import { screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" +import { fireEvent, screen, waitFor } from "@testing-library/react" import React from "react" import * as API from "../../api" import * as AccountForm from "../../components/Preferences/AccountForm" @@ -25,10 +24,10 @@ const newData = { const fillAndSubmitForm = async () => { await waitFor(() => screen.findByLabelText("Name")) - await userEvent.type(screen.getByLabelText("Name"), newData.name) - await userEvent.type(screen.getByLabelText("Email"), newData.email) - await userEvent.type(screen.getByLabelText("Username"), newData.username) - await userEvent.click(screen.getByText(AccountForm.Language.updatePreferences)) + 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", () => { @@ -59,7 +58,9 @@ describe("PreferencesAccountPage", () => { it("shows an error", async () => { jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ isAxiosError: true, - response: { data: { errors: [{ detail: "Email is already in use", field: "email" }] } }, + response: { + data: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] }, + }, }) const { user } = renderPage() @@ -76,7 +77,9 @@ describe("PreferencesAccountPage", () => { it("shows an error", async () => { jest.spyOn(API, "updateProfile").mockRejectedValueOnce({ isAxiosError: true, - response: { data: { errors: [{ detail: "Username is already in use", field: "username" }] } }, + response: { + data: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] }, + }, }) const { user } = renderPage() diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 55bbdde29eed9..087ce9eb441ba 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -14,7 +14,9 @@ export const PreferencesAccountPage: React.FC = () => { const xServices = useContext(XServiceContext) const [authState, authSend] = useActor(xServices.authXService) const { me, updateProfileError } = authState.context - const formErrors = isApiError(updateProfileError) ? mapApiErrorToFieldErrors(updateProfileError) : undefined + const formErrors = isApiError(updateProfileError) + ? mapApiErrorToFieldErrors(updateProfileError.response.data) + : undefined if (!me) { throw new Error("No current user found") From a0588d1b0235bcf781dad85d6358468213b75309 Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 18:11:41 +0000 Subject: [PATCH 19/28] feat: handle unknown error --- .../src/components/Preferences/AccountForm.tsx | 16 ++++++++++------ site/src/pages/preferences/account.test.tsx | 18 +++++++++++++++++- site/src/pages/preferences/account.tsx | 13 ++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 55bf7b5d865c9..cc339ebacc0ce 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -1,3 +1,4 @@ +import FormHelperText from "@material-ui/core/FormHelperText" import TextField from "@material-ui/core/TextField" import { FormikContextType, FormikErrors, useFormik } from "formik" import React, { useEffect } from "react" @@ -32,22 +33,23 @@ export interface AccountFormProps { isLoading: boolean initialValues: AccountFormValues onSubmit: (values: AccountFormValues) => void - errors?: AccountFormErrors + formErrors?: AccountFormErrors + error?: string } -export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues, errors }) => { +export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues, formErrors, error }) => { const form: FormikContextType = useFormik({ initialValues, validationSchema, onSubmit, }) - // Sync errors from parent + // Sync formErrors from parent useEffect(() => { - if (errors) { - form.setErrors(errors) + if (formErrors) { + form.setErrors(formErrors) } - }, [errors, form]) + }, [formErrors, form]) return ( <> @@ -78,6 +80,8 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i variant="outlined" /> + {error && {error}} +
{isLoading ? "" : Language.updatePreferences} diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index e105c2d1b90d5..675e9e9e285dd 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -5,7 +5,7 @@ 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 { PreferencesAccountPage } from "./account" +import { Language, PreferencesAccountPage } from "./account" const renderPage = () => { return renderWithAuth( @@ -91,4 +91,20 @@ describe("PreferencesAccountPage", () => { 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) + }) + }) }) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 087ce9eb441ba..9c20f37a58983 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -5,18 +5,20 @@ import { AccountForm } from "../../components/Preferences/AccountForm" import { Section } from "../../components/Section" import { XServiceContext } from "../../xServices/StateContext" -const Language = { +export const Language = { title: "Account", description: "Update your display name, email, and username.", + unknownError: "Oops, an unknown error happened.", } export const PreferencesAccountPage: React.FC = () => { const xServices = useContext(XServiceContext) const [authState, authSend] = useActor(xServices.authXService) const { me, updateProfileError } = authState.context - const formErrors = isApiError(updateProfileError) - ? mapApiErrorToFieldErrors(updateProfileError.response.data) - : undefined + const hasError = !!updateProfileError + const formErrors = + hasError && isApiError(updateProfileError) ? mapApiErrorToFieldErrors(updateProfileError.response.data) : undefined + const hasUnknownError = hasError && !isApiError(updateProfileError) if (!me) { throw new Error("No current user found") @@ -26,7 +28,8 @@ export const PreferencesAccountPage: React.FC = () => { <>
{ From b3159d03a047b6f446f4ab04c4d680406505b47e Mon Sep 17 00:00:00 2001 From: Bruno Date: Thu, 14 Apr 2022 19:01:02 +0000 Subject: [PATCH 20/28] fix: make the eslint-disable inline --- site/src/api/errors.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 159a30613570e..c05a0b2da5f22 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import axios, { AxiosError, AxiosResponse } from "axios" export const Language = { @@ -22,6 +20,7 @@ export interface ApiErrorResponse { export type ApiError = AxiosError & { response: AxiosResponse } +// 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 From e07d717a6ab1d92ce1656b2b23fe37a447eaa2ef Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 16:01:23 -0300 Subject: [PATCH 21/28] chore: rename story Co-authored-by: G r e y --- site/src/components/Stack/Stack.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/Stack/Stack.stories.tsx b/site/src/components/Stack/Stack.stories.tsx index b535c6b558b9e..b10df77d80369 100644 --- a/site/src/components/Stack/Stack.stories.tsx +++ b/site/src/components/Stack/Stack.stories.tsx @@ -4,7 +4,7 @@ import React from "react" import { Stack, StackProps } from "./Stack" export default { - title: "Stack/Stack", + title: "Components/Stack", component: Stack, } From 8d63848db19e590d43059e27d55125d2da05e59c Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 16:29:36 -0300 Subject: [PATCH 22/28] Update site/src/xServices/auth/authXService.ts Co-authored-by: G r e y --- site/src/xServices/auth/authXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 6a5743baa7fb0..f6e25c0c1833b 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -4,7 +4,7 @@ import * as Types from "../../api/types" import { displaySuccess } from "../../components/Snackbar" export const Language = { - successProfileUpdate: "Preferences updated successfully!", + successProfileUpdate: "Updated preferences.", } export interface AuthContext { getUserError?: Error | unknown From a11ff105da7ca5285ca7fc0390e94c564f8dd7a1 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 14 Apr 2022 16:29:44 -0300 Subject: [PATCH 23/28] Update site/src/pages/preferences/account.tsx Co-authored-by: G r e y --- site/src/pages/preferences/account.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 9c20f37a58983..9fa09f3acf2d0 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -8,7 +8,7 @@ import { XServiceContext } from "../../xServices/StateContext" export const Language = { title: "Account", description: "Update your display name, email, and username.", - unknownError: "Oops, an unknown error happened.", + unknownError: "Oops, an unknown error occurred.", } export const PreferencesAccountPage: React.FC = () => { From bde7c1521d85a62ca4997ed7876b759241bdc730 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 15 Apr 2022 13:00:20 +0000 Subject: [PATCH 24/28] Fix errors --- site/src/components/Form/index.ts | 13 ++++++-- .../components/Preferences/AccountForm.tsx | 23 +++++++------- site/src/pages/preferences/account.test.tsx | 2 +- site/src/pages/preferences/account.tsx | 30 +++++++++---------- site/src/xServices/auth/authXService.ts | 5 ++++ 5 files changed, 41 insertions(+), 32 deletions(-) diff --git a/site/src/components/Form/index.ts b/site/src/components/Form/index.ts index df13aff05b06d..04792b1d1a711 100644 --- a/site/src/components/Form/index.ts +++ b/site/src/components/Form/index.ts @@ -1,4 +1,4 @@ -import { FormikContextType, getIn } from "formik" +import { FormikContextType, FormikErrors, getIn } from "formik" import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react" export * from "./FormCloseButton" @@ -17,10 +17,17 @@ interface FormHelpers { helperText?: string } -export const getFormHelpers = (form: FormikContextType, name: string): FormHelpers => { +export const getFormHelpers = ( + form: FormikContextType, + name: string, + additionalErrors: FormikErrors = {}, +): 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 = { + ...getIn(form.errors, name), + ...additionalErrors, + } return { ...form.getFieldProps(name), id: name, diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index cc339ebacc0ce..35a8b17304bd3 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -1,7 +1,7 @@ import FormHelperText from "@material-ui/core/FormHelperText" import TextField from "@material-ui/core/TextField" import { FormikContextType, FormikErrors, useFormik } from "formik" -import React, { useEffect } from "react" +import React from "react" import * as Yup from "yup" import { getFormHelpers, onChangeTrimmed } from "../Form" import { Stack } from "../Stack/Stack" @@ -37,26 +37,25 @@ export interface AccountFormProps { error?: string } -export const AccountForm: React.FC = ({ isLoading, onSubmit, initialValues, formErrors, error }) => { +export const AccountForm: React.FC = ({ + isLoading, + onSubmit, + initialValues, + formErrors = {}, + error, +}) => { const form: FormikContextType = useFormik({ initialValues, validationSchema, onSubmit, }) - // Sync formErrors from parent - useEffect(() => { - if (formErrors) { - form.setErrors(formErrors) - } - }, [formErrors, form]) - return ( <>
(form, "name")} + {...getFormHelpers(form, "name", formErrors)} autoFocus autoComplete="name" fullWidth @@ -64,7 +63,7 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i variant="outlined" /> (form, "email")} + {...getFormHelpers(form, "email", formErrors)} onChange={onChangeTrimmed(form)} autoComplete="email" fullWidth @@ -72,7 +71,7 @@ export const AccountForm: React.FC = ({ isLoading, onSubmit, i variant="outlined" /> (form, "username")} + {...getFormHelpers(form, "username", formErrors)} onChange={onChangeTrimmed(form)} autoComplete="username" fullWidth diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 675e9e9e285dd..326ed060b22d7 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -36,7 +36,7 @@ describe("PreferencesAccountPage", () => { }) describe("when it is a success", () => { - it("shows the success message", async () => { + it.only("shows the success message", async () => { jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) => Promise.resolve({ id: userId, diff --git a/site/src/pages/preferences/account.tsx b/site/src/pages/preferences/account.tsx index 9fa09f3acf2d0..fd507337234a4 100644 --- a/site/src/pages/preferences/account.tsx +++ b/site/src/pages/preferences/account.tsx @@ -25,21 +25,19 @@ export const PreferencesAccountPage: React.FC = () => { } return ( - <> -
- { - authSend({ - type: "UPDATE_PROFILE", - data, - }) - }} - /> -
- +
+ { + authSend({ + type: "UPDATE_PROFILE", + data, + }) + }} + /> +
) } diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index f6e25c0c1833b..0cfc15ced6b72 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -106,8 +106,10 @@ export const authMachine = }, }, updatingProfile: { + entry: ["clearUpdateProfileError"], invoke: { src: "updateProfile", + onDone: [ { actions: ["assignMe", "notifySuccessProfileUpdate"], @@ -195,6 +197,9 @@ export const authMachine = notifySuccessProfileUpdate: () => { displaySuccess(Language.successProfileUpdate) }, + clearUpdateProfileError: assign({ + updateProfileError: (_) => undefined, + }), }, }, ) From bbf2152859f34f62aafe39fca5c5208fdc392941 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 15 Apr 2022 13:10:28 +0000 Subject: [PATCH 25/28] Fix type --- site/src/test_helpers/entities.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index 099d0fe59fcaa..bdccc8bb806ac 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -29,6 +29,7 @@ export const MockUser: UserResponse = { export const MockUser2: UserResponse = { id: "test-user-2", + name: "Test User 2", username: "TestUser2", email: "test2@coder.com", created_at: "", From 7f3260040af53856f24860e05ff8f6f6be95bd78 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 15 Apr 2022 13:22:51 +0000 Subject: [PATCH 26/28] Fix forms --- site/src/components/Form/index.ts | 13 +++---------- site/src/components/Preferences/AccountForm.tsx | 6 +++--- site/src/pages/preferences/account.test.tsx | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/site/src/components/Form/index.ts b/site/src/components/Form/index.ts index 04792b1d1a711..3eae433c00162 100644 --- a/site/src/components/Form/index.ts +++ b/site/src/components/Form/index.ts @@ -1,4 +1,4 @@ -import { FormikContextType, FormikErrors, getIn } from "formik" +import { FormikContextType, getIn } from "formik" import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react" export * from "./FormCloseButton" @@ -17,17 +17,10 @@ interface FormHelpers { helperText?: string } -export const getFormHelpers = ( - form: FormikContextType, - name: string, - additionalErrors: FormikErrors = {}, -): FormHelpers => { +export const getFormHelpers = (form: FormikContextType, 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), - ...additionalErrors, - } + const errors = error ?? getIn(form.errors, name) return { ...form.getFieldProps(name), id: name, diff --git a/site/src/components/Preferences/AccountForm.tsx b/site/src/components/Preferences/AccountForm.tsx index 35a8b17304bd3..b851322530d3b 100644 --- a/site/src/components/Preferences/AccountForm.tsx +++ b/site/src/components/Preferences/AccountForm.tsx @@ -55,7 +55,7 @@ export const AccountForm: React.FC = ({ (form, "name", formErrors)} + {...getFormHelpers(form, "name")} autoFocus autoComplete="name" fullWidth @@ -63,7 +63,7 @@ export const AccountForm: React.FC = ({ variant="outlined" /> (form, "email", formErrors)} + {...getFormHelpers(form, "email", formErrors.email)} onChange={onChangeTrimmed(form)} autoComplete="email" fullWidth @@ -71,7 +71,7 @@ export const AccountForm: React.FC = ({ variant="outlined" /> (form, "username", formErrors)} + {...getFormHelpers(form, "username", formErrors.username)} onChange={onChangeTrimmed(form)} autoComplete="username" fullWidth diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 326ed060b22d7..675e9e9e285dd 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -36,7 +36,7 @@ describe("PreferencesAccountPage", () => { }) describe("when it is a success", () => { - it.only("shows the success message", async () => { + it("shows the success message", async () => { jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) => Promise.resolve({ id: userId, From eb65490deeb956f58f30cec1d922a5334b28106d Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 15 Apr 2022 13:24:40 +0000 Subject: [PATCH 27/28] Normalize machine --- site/src/xServices/auth/authXService.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 0cfc15ced6b72..4dddf9f901ab0 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -19,7 +19,7 @@ export type AuthEvent = | { type: "UPDATE_PROFILE"; data: Types.UpdateProfileRequest } export const authMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogCMABgCcAFkLyZAVilS5GgEwAOAMwA2ADQgAnogDsa5YcOXD2y7oWH92-QF9PptFlz4RKQUJORQDOS0ECJEoQBufADWQWTkEWL8gsKiSBKI7kraqpZSuh7aUvpy6vqmFghlcoT6qgb6GsUlctrevhg4eATEqaHhkWAAThN8E4Q8ADb4AGYzALbDFOm5mSRCImKSCLKKynJqGlpSekZ1iIaltvaOzq4FvSB+A4GEMOhCYQBVWCTKIxQjxJJEX4AWTAGQEu2yB0QrikhG0Mn0+l0ulUWJkhlatXMKKqynkCn0lOscnsUnenwCQ1+-ygQJBk2mswWyzWPzA6Fh8Ky+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmRFQPAg+FGAAVLdawKDKODyAlkoRGYMTZQIOaeK6bYQ7Q7WS6+FabQgIQBjR0iADaMgAusLEaLQIcNJY1YpZB5pQpJVJLMqMbpCKVulVCY5aZXDf4AxsgyGw7b7Y6wjG4+7OTM5ot0CsJut-d9gl3yBbY26I33oz2E96+Mm9uR01ntgid8iGlpCLpLNoFJXpYXHLplbr9IRi4oZDJdHJLIpim2vkM52aC6hkuNq0ACToACIAIIACoAKJsE6ABKVAAGIMAAMnB2ZHmKiDnjI6oOG4ihVM4yqfuiVS6hqEqFooBo+B8RodgBwaRIwrBsFQAIwThSJ4UcWLorSWKqDIlhGJWlhViSCCUaWNGOE4qiKHIv7Gp2ow0OgHqxJuvpzjp-G5nkCDuE+F5yLohiUqU2oeMqOq1moH4XrZL5eExM7-iMYQ6bQI7cuOk7rEZGAmTkeaIEUaruB+CjqFcRL6LJ9RFIRqUKFexQZQYEreEx5B8BAcBiD5gbUBFB4ilFZnqIYhBuNSajYoS1nKoWT4KAYqkeO0GKGOp3ksbOfljJFx5XPKdZ4hJ0qfu0ypDdoZ4ahe4klLiegaR2LKjOyEyTYJ5bKroGpnkSWiSbimiqLtY2muxi5DuCEDhsVcFTDMx3RUc0lnoWb4KJJhJag+F1ZZSqiDdZGKWA9vlPd2IGxO9RBBb9ZkFpYgPiTIINGOJSpya4jXiYo2WfvqEkSYjlXPcBr0kOjWP5lIeJ48DoPE8qMrNJY2VXKUzy6PTnaAS9y6Rv2UCDm6bP4QYhD0ZUqUltiUjKndTUfqWTaSvcCMje2j3zlLNqKw0rhEXY1FkfeclXFRN42Vqah4oYYsm3+DNbLwh4CX9ZSre0xH25+jv1AAtDNDgEgSqhJ7ZuKEuLc7adVAe1ce7iEFTl6FkUOIyFIChOeJyitGWpQEho9I+8aVu1gXIMw60uil+XcnR2rKvyvKydnBzrSFZ4QA */ + /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogCMABgCcAFkLyZAVilS5GgEwAOAMwA2ADQgAnogDsa5YcOXD2y7oWH92-QF9PptFlz4RKQUJORQDOS0ECJEoQBufADWQWTkEWL8gsKiSBKI7kraqpZSuh7aUvpy6vqmFghlcoT6qgb6GsUlctrevhg4eATEqaHhkWAAThN8E4Q8ADb4AGYzALbDFOm5mSRCImKSCLKKynJqGlpSekZ1iIaltvaOzq4FvSB+A4GEMOhCYQBVWCTKIxQjxJJEX4AWTAGQEu2yB0QrikhG0Mn0+l0ulUWJkhlatXMKKqynkCn0lOscnsUnenwCQ1+-ygQJBk2mswWyzWPzA6Fh8Ky+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmsQgNtoAIACgARACCABUAKJsR0AJSoADEGAAZT3CxGi0CHGTKmSG-yDE2UCDmniW61EVA8CD4UaO9P26KUcHkBLJQiMxMbZOpguZ7O5sL5vhWm0ICEAY1zIgA2jIALrhvY5KPSKSWNWKWQeaUKSXj5UY3SEUrdKqExy08fxr5DYI18gWlsZwhZnOs5utsC0TkzOaLdArCbrSvffdmw9p48208Ni919tSz4Lthz7QdtgRYdkQaLRCF0SxtAUcdpQnRxdGVXV9EIKdFBkGRdDkSxFGKHdjWrD96GYdgqABd0hyRMVpCxdFaSxVQZEsIxx0sSxlUI9Eql1DUJQnRQ5FIqt91GGh0FBYsIXLfcZPoyM8gQdwsIQuRdEMSlSm1DxlR1Zc1AIhDdJwrwfA+I1JJGMIZJvKY7x5R8+SUjAVJHNSijVdwCIUdQriJfReJJBAihkZpLAUJDikigwJW8azyD4CA4DEV891SahPIgkVvMOdRDEINxqTUbFCW05UJywhQDFUNd2gxQxxOsrKk1GLZeEghjRyOfUoqkPEOOlQj2mVVrtDgjUEPYkpcT0CTvhZUZ2QmLzoLnZVdA1OCiS0TjcU0VRluy00U0-OtwTtIhUs9ZyNvyiNCrHHi4InPCFE4wktQwvbQtiloWu0jFLDOpMPyPK8bp-W8np6groI0d74PYmRvqMdilXC1wSvYxRYsIwbrHB9rbLfHLLuhk8SFuzbGKOYasLRr6fux5UZWi2KCclXarL6BNKYu2tv3rc88zrBn+pxTTGsqULp2xKRlRO0qCJnDdJXuMnBd3SHqa-K9pbUgBaRDlVNzjyTwjpOM+1QDXJoXzoPE3DlN+rLakVwba1dpvvYjQIeraS8sRl7oPcQgicQicihxGQfaM9jlFaWdSgJDR6Wd-X3cQZdY8DhPdCThRLY8dUOPsBwDE4wjdGSzwgA */ createMachine( { context: { me: undefined, getUserError: undefined, authError: undefined, updateProfileError: undefined }, @@ -94,22 +94,21 @@ export const authMachine = initial: "idle", states: { idle: { - on: { - UPDATE_PROFILE: { - target: "#authState.signedIn.profile.updatingProfile", - }, - }, initial: "noError", states: { noError: {}, error: {}, }, + on: { + UPDATE_PROFILE: { + target: "updatingProfile", + }, + }, }, updatingProfile: { - entry: ["clearUpdateProfileError"], + entry: "clearUpdateProfileError", invoke: { src: "updateProfile", - onDone: [ { actions: ["assignMe", "notifySuccessProfileUpdate"], From 59bac76c8189e935db323ce9920fef567de80094 Mon Sep 17 00:00:00 2001 From: Bruno Date: Fri, 15 Apr 2022 14:10:40 +0000 Subject: [PATCH 28/28] Fix: tests --- site/jest.setup.ts | 5 ++++- site/src/api/index.ts | 2 +- site/src/pages/preferences/account.test.tsx | 4 ---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 90c669b04d6ab..32d80012bbc08 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -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()) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index c18bcd11d4f81..7d2b664f357fa 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -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 = { diff --git a/site/src/pages/preferences/account.test.tsx b/site/src/pages/preferences/account.test.tsx index 675e9e9e285dd..a99aad3a38d83 100644 --- a/site/src/pages/preferences/account.test.tsx +++ b/site/src/pages/preferences/account.test.tsx @@ -31,10 +31,6 @@ const fillAndSubmitForm = async () => { } describe("PreferencesAccountPage", () => { - afterEach(() => { - jest.clearAllMocks() - }) - describe("when it is a success", () => { it("shows the success message", async () => { jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>