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) => pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy