feat: add the preferences/account page (#999)

This commit is contained in:
Bruno Quaresma
2022-04-15 13:17:50 -04:00
committed by GitHub
parent c853eb3350
commit 88e30bec55
15 changed files with 480 additions and 25 deletions
+4 -1
View File
@@ -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())
+38
View File
@@ -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",
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import axios, { AxiosError, AxiosResponse } from "axios"
export const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
}
interface FieldError {
field: string
detail: string
}
type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
export interface ApiErrorResponse {
message: string
errors?: FieldError[]
}
export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
export const isApiError = (err: any): err is ApiError => {
if (axios.isAxiosError(err)) {
const response = err.response?.data
return (
typeof response.message === "string" && (typeof response.errors === "undefined" || Array.isArray(response.errors))
)
}
return false
}
export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => {
const result: FieldErrors = {}
if (apiErrorResponse.errors) {
for (const error of apiErrorResponse.errors) {
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode
}
}
return result
}
+6 -1
View File
@@ -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 = {
@@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async (
headers: { ...CONTENT_TYPE_JSON },
})
}
export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.UserResponse> => {
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
return response.data
}
+7
View File
@@ -15,6 +15,7 @@ export interface UserResponse {
readonly username: string
readonly email: string
readonly created_at: string
readonly name: string
}
/**
@@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
export interface WorkspaceAutostopRequest {
schedule: string
}
export interface UpdateProfileRequest {
readonly username: string
readonly email: string
readonly name: string
}
+2 -2
View File
@@ -17,10 +17,10 @@ interface FormHelpers {
helperText?: string
}
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string, error?: string): FormHelpers => {
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
const touched = getIn(form.touched, name)
const errors = getIn(form.errors, name)
const errors = error ?? getIn(form.errors, name)
return {
...form.getFieldProps(name),
id: name,
+1 -1
View File
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
const location = useLocation()
const redirectTo = embedRedirect(location.pathname)
if (authState.matches("signedOut") || !authState.context.me) {
if (authState.matches("signedOut")) {
return <Navigate to={redirectTo} />
} else if (authState.hasTag("loading")) {
return <FullScreenLoader />
@@ -0,0 +1,93 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../Form"
import { Stack } from "../Stack/Stack"
import { LoadingButton } from "./../Button"
interface AccountFormValues {
name: string
email: string
username: string
}
export const Language = {
nameLabel: "Name",
usernameLabel: "Username",
emailLabel: "Email",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
updatePreferences: "Update preferences",
}
const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
name: Yup.string().optional(),
username: Yup.string().trim(),
})
export type AccountFormErrors = FormikErrors<AccountFormValues>
export interface AccountFormProps {
isLoading: boolean
initialValues: AccountFormValues
onSubmit: (values: AccountFormValues) => void
formErrors?: AccountFormErrors
error?: string
}
export const AccountForm: React.FC<AccountFormProps> = ({
isLoading,
onSubmit,
initialValues,
formErrors = {},
error,
}) => {
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
initialValues,
validationSchema,
onSubmit,
})
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFormHelpers<AccountFormValues>(form, "name")}
autoFocus
autoComplete="name"
fullWidth
label={Language.nameLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<div>
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePreferences}
</LoadingButton>
</div>
</Stack>
</form>
</>
)
}
@@ -0,0 +1,22 @@
import TextField from "@material-ui/core/TextField"
import { Story } from "@storybook/react"
import React from "react"
import { Stack, StackProps } from "./Stack"
export default {
title: "Components/Stack",
component: Stack,
}
const Template: Story<StackProps> = (args: StackProps) => (
<Stack {...args}>
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
</Stack>
)
export const Example = Template.bind({})
Example.args = {
spacing: 2,
}
+19
View File
@@ -0,0 +1,19 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
export interface StackProps {
spacing?: number
}
const useStyles = makeStyles((theme) => ({
stack: {
display: "flex",
flexDirection: "column",
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
},
}))
export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
const styles = useStyles({ spacing })
return <div className={styles.stack}>{children}</div>
}
+106
View File
@@ -0,0 +1,106 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import * as AccountForm from "../../components/Preferences/AccountForm"
import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar"
import { renderWithAuth } from "../../test_helpers"
import * as AuthXService from "../../xServices/auth/authXService"
import { Language, PreferencesAccountPage } from "./account"
const renderPage = () => {
return renderWithAuth(
<>
<PreferencesAccountPage />
<GlobalSnackbar />
</>,
)
}
const newData = {
name: "User",
email: "user@coder.com",
username: "user",
}
const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Name"))
fireEvent.change(screen.getByLabelText("Name"), { target: { value: newData.name } })
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } })
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } })
fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences))
}
describe("PreferencesAccountPage", () => {
describe("when it is a success", () => {
it("shows the success message", async () => {
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({
id: userId,
...data,
created_at: new Date().toString(),
}),
)
const { user } = renderPage()
await fillAndSubmitForm()
const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate)
expect(successMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
describe("when the email is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] },
},
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText("Email is already in use")
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
describe("when the username is already taken", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] },
},
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText("Username is already in use")
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
data: "unknown error",
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText(Language.unknownError)
expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData)
})
})
})
+36 -4
View File
@@ -1,11 +1,43 @@
import React from "react"
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { isApiError, mapApiErrorToFieldErrors } from "../../api/errors"
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, profile picture, and dotfiles preferences.",
description: "Update your display name, email, and username.",
unknownError: "Oops, an unknown error occurred.",
}
export const PreferencesAccountPage: React.FC = () => {
return <Section title={Language.title} description={Language.description} />
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { me, updateProfileError } = authState.context
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")
}
return (
<Section title={Language.title} description={Language.description}>
<AccountForm
error={hasUnknownError ? Language.unknownError : undefined}
formErrors={formErrors}
isLoading={authState.matches("signedIn.profile.updatingProfile")}
initialValues={{ name: me.name, username: me.username, email: me.email }}
onSubmit={(data) => {
authSend({
type: "UPDATE_PROFILE",
data,
})
}}
/>
</Section>
)
}
+2
View File
@@ -20,6 +20,7 @@ export const MockBuildInfo: BuildInfoResponse = {
}
export const MockUser: UserResponse = {
name: "Test User",
id: "test-user",
username: "TestUser",
email: "test@coder.com",
@@ -28,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: "",
+22 -1
View File
@@ -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(<WrapperComponent>{component}</WrapperComponent>)
}
type RenderWithAuthResult = RenderResult & { user: typeof MockUser }
export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult {
const renderResult = wrappedRender(
<MemoryRouter initialEntries={[route]}>
<XServiceProvider>
<Routes>
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
</Routes>
</XServiceProvider>
</MemoryRouter>,
)
return {
user: MockUser,
...renderResult,
}
}
export * from "./entities"
+76 -15
View File
@@ -1,19 +1,28 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api"
import * as Types from "../../api/types"
import { displaySuccess } from "../../components/Snackbar"
export const Language = {
successProfileUpdate: "Updated preferences.",
}
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+1yh202iacip2I8hjU9m0twQ1lUzSk9hkHlUCjU2kMDP6TJSFEgETm0yWJHmsQgNtoAIACgARACCABUAKJsR0AJSoADEGAAZT3CxGi0CHGTKmSG-yDE2UCDmniW61EVA8CD4UaO9P26KUcHkBLJQiMxMbZOpguZ7O5sL5vhWm0ICEAY1zIgA2jIALrhvY5KPSKSWNWKWQeaUKSXj5UY3SEUrdKqExy08fxr5DYI18gWlsZwhZnOs5utsC0TkzOaLdArCbrSvffdmw9p48208Ni919tSz4Lthz7QdtgRYdkQaLRCF0SxtAUcdpQnRxdGVXV9EIKdFBkGRdDkSxFGKHdjWrD96GYdgqABd0hyRMVpCxdFaSxVQZEsIxx0sSxlUI9Eql1DUJQnRQ5FIqt91GGh0FBYsIXLfcZPoyM8gQdwsIQuRdEMSlSm1DxlR1Zc1AIhDdJwrwfA+I1JJGMIZJvKY7x5R8+SUjAVJHNSijVdwCIUdQriJfReJJBAihkZpLAUJDikigwJW8azyD4CA4DEV891SahPIgkVvMOdRDEINxqTUbFCW05UJywhQDFUNd2gxQxxOsrKk1GLZeEghjRyOfUoqkPEOOlQj2mVVrtDgjUEPYkpcT0CTvhZUZ2QmLzoLnZVdA1OCiS0TjcU0VRluy00U0-OtwTtIhUs9ZyNvyiNCrHHi4InPCFE4wktQwvbQtiloWu0jFLDOpMPyPK8bp-W8np6groI0d74PYmRvqMdilXC1wSvYxRYsIwbrHB9rbLfHLLuhk8SFuzbGKOYasLRr6fux5UZWi2KCclXarL6BNKYu2tv3rc88zrBn+pxTTGsqULp2xKRlRO0qCJnDdJXuMnBd3SHqa-K9pbUgBaRDlVNzjyTwjpOM+1QDXJoXzoPE3DlN+rLakVwba1dpvvYjQIeraS8sRl7oPcQgicQicihxGQfaM9jlFaWdSgJDR6Wd-X3cQZdY8DhPdCThRLY8dUOPsBwDE4wjdGSzwgA */
createMachine(
{
context: { me: undefined, getUserError: undefined, authError: undefined, updateProfileError: undefined },
tsTypes: {} as import("./authXService.typegen").Typegen0,
schema: {
context: {} as AuthContext,
@@ -25,20 +34,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 +55,14 @@ export const authMachine =
id: "signIn",
onDone: [
{
target: "#authState.gettingUser",
actions: "clearAuthError",
target: "gettingUser",
},
],
onError: [
{
actions: "assignAuthError",
target: "#authState.signedOut",
target: "signedOut",
},
],
},
@@ -68,22 +75,60 @@ 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: {
initial: "idle",
states: {
idle: {
initial: "noError",
states: {
noError: {},
error: {},
},
on: {
UPDATE_PROFILE: {
target: "updatingProfile",
},
},
},
updatingProfile: {
entry: "clearUpdateProfileError",
invoke: {
src: "updateProfile",
onDone: [
{
actions: ["assignMe", "notifySuccessProfileUpdate"],
target: "#authState.signedIn.profile.idle.noError",
},
],
onError: [
{
actions: "assignUpdateProfileError",
target: "#authState.signedIn.profile.idle.error",
},
],
},
},
},
},
},
on: {
SIGN_OUT: {
target: "#authState.signingOut",
target: "signingOut",
},
},
},
@@ -94,13 +139,13 @@ export const authMachine =
onDone: [
{
actions: ["unassignMe", "clearAuthError"],
target: "#authState.signedOut",
target: "signedOut",
},
],
onError: [
{
actions: "assignAuthError",
target: "#authState.signedIn",
target: "signedIn",
},
],
},
@@ -115,6 +160,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 +190,15 @@ export const authMachine =
...context,
authError: undefined,
})),
assignUpdateProfileError: assign({
updateProfileError: (_, event) => event.data,
}),
notifySuccessProfileUpdate: () => {
displaySuccess(Language.successProfileUpdate)
},
clearUpdateProfileError: assign({
updateProfileError: (_) => undefined,
}),
},
},
)