Skip to content

Commit 88e30be

Browse files
feat: add the preferences/account page (#999)
1 parent c853eb3 commit 88e30be

File tree

15 files changed

+480
-25
lines changed

15 files changed

+480
-25
lines changed

site/jest.setup.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ beforeAll(() =>
99

1010
// Reset any request handlers that we may add during the tests,
1111
// so they don't affect other tests.
12-
afterEach(() => server.resetHandlers())
12+
afterEach(() => {
13+
server.resetHandlers()
14+
jest.clearAllMocks()
15+
})
1316

1417
// Clean up after the tests are finished.
1518
afterAll(() => server.close())

site/src/api/errors.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { isApiError, mapApiErrorToFieldErrors } from "./errors"
2+
3+
describe("isApiError", () => {
4+
it("returns true when the object is an API Error", () => {
5+
expect(
6+
isApiError({
7+
isAxiosError: true,
8+
response: {
9+
data: {
10+
message: "Invalid entry",
11+
errors: [{ detail: "Username is already in use", field: "username" }],
12+
},
13+
},
14+
}),
15+
).toBe(true)
16+
})
17+
18+
it("returns false when the object is Error", () => {
19+
expect(isApiError(new Error())).toBe(false)
20+
})
21+
22+
it("returns false when the object is undefined", () => {
23+
expect(isApiError(undefined)).toBe(false)
24+
})
25+
})
26+
27+
describe("mapApiErrorToFieldErrors", () => {
28+
it("returns correct field errors", () => {
29+
expect(
30+
mapApiErrorToFieldErrors({
31+
message: "Invalid entry",
32+
errors: [{ detail: "Username is already in use", field: "username" }],
33+
}),
34+
).toEqual({
35+
username: "Username is already in use",
36+
})
37+
})
38+
})

site/src/api/errors.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import axios, { AxiosError, AxiosResponse } from "axios"
2+
3+
export const Language = {
4+
errorsByCode: {
5+
defaultErrorCode: "Invalid value",
6+
},
7+
}
8+
9+
interface FieldError {
10+
field: string
11+
detail: string
12+
}
13+
14+
type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
15+
16+
export interface ApiErrorResponse {
17+
message: string
18+
errors?: FieldError[]
19+
}
20+
21+
export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }
22+
23+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
24+
export const isApiError = (err: any): err is ApiError => {
25+
if (axios.isAxiosError(err)) {
26+
const response = err.response?.data
27+
28+
return (
29+
typeof response.message === "string" && (typeof response.errors === "undefined" || Array.isArray(response.errors))
30+
)
31+
}
32+
33+
return false
34+
}
35+
36+
export const mapApiErrorToFieldErrors = (apiErrorResponse: ApiErrorResponse): FieldErrors => {
37+
const result: FieldErrors = {}
38+
39+
if (apiErrorResponse.errors) {
40+
for (const error of apiErrorResponse.errors) {
41+
result[error.field] = error.detail || Language.errorsByCode.defaultErrorCode
42+
}
43+
}
44+
45+
return result
46+
}

site/src/api/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios, { AxiosRequestHeaders } from "axios"
22
import { mutate } from "swr"
3-
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
3+
import { MockPager, MockUser, MockUser2 } from "../test_helpers/entities"
44
import * as Types from "./types"
55

66
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
@@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async (
103103
headers: { ...CONTENT_TYPE_JSON },
104104
})
105105
}
106+
107+
export const updateProfile = async (userId: string, data: Types.UpdateProfileRequest): Promise<Types.UserResponse> => {
108+
const response = await axios.put(`/api/v2/users/${userId}/profile`, data)
109+
return response.data
110+
}

site/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface UserResponse {
1515
readonly username: string
1616
readonly email: string
1717
readonly created_at: string
18+
readonly name: string
1819
}
1920

2021
/**
@@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
9596
export interface WorkspaceAutostopRequest {
9697
schedule: string
9798
}
99+
100+
export interface UpdateProfileRequest {
101+
readonly username: string
102+
readonly email: string
103+
readonly name: string
104+
}

site/src/components/Form/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ interface FormHelpers {
1717
helperText?: string
1818
}
1919

20-
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
20+
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string, error?: string): FormHelpers => {
2121
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
2222
const touched = getIn(form.touched, name)
23-
const errors = getIn(form.errors, name)
23+
const errors = error ?? getIn(form.errors, name)
2424
return {
2525
...form.getFieldProps(name),
2626
id: name,

site/src/components/Page/RequireAuth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
1515
const location = useLocation()
1616
const redirectTo = embedRedirect(location.pathname)
1717

18-
if (authState.matches("signedOut") || !authState.context.me) {
18+
if (authState.matches("signedOut")) {
1919
return <Navigate to={redirectTo} />
2020
} else if (authState.hasTag("loading")) {
2121
return <FullScreenLoader />
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import FormHelperText from "@material-ui/core/FormHelperText"
2+
import TextField from "@material-ui/core/TextField"
3+
import { FormikContextType, FormikErrors, useFormik } from "formik"
4+
import React from "react"
5+
import * as Yup from "yup"
6+
import { getFormHelpers, onChangeTrimmed } from "../Form"
7+
import { Stack } from "../Stack/Stack"
8+
import { LoadingButton } from "./../Button"
9+
10+
interface AccountFormValues {
11+
name: string
12+
email: string
13+
username: string
14+
}
15+
16+
export const Language = {
17+
nameLabel: "Name",
18+
usernameLabel: "Username",
19+
emailLabel: "Email",
20+
emailInvalid: "Please enter a valid email address.",
21+
emailRequired: "Please enter an email address.",
22+
updatePreferences: "Update preferences",
23+
}
24+
25+
const validationSchema = Yup.object({
26+
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
27+
name: Yup.string().optional(),
28+
username: Yup.string().trim(),
29+
})
30+
31+
export type AccountFormErrors = FormikErrors<AccountFormValues>
32+
export interface AccountFormProps {
33+
isLoading: boolean
34+
initialValues: AccountFormValues
35+
onSubmit: (values: AccountFormValues) => void
36+
formErrors?: AccountFormErrors
37+
error?: string
38+
}
39+
40+
export const AccountForm: React.FC<AccountFormProps> = ({
41+
isLoading,
42+
onSubmit,
43+
initialValues,
44+
formErrors = {},
45+
error,
46+
}) => {
47+
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
48+
initialValues,
49+
validationSchema,
50+
onSubmit,
51+
})
52+
53+
return (
54+
<>
55+
<form onSubmit={form.handleSubmit}>
56+
<Stack>
57+
<TextField
58+
{...getFormHelpers<AccountFormValues>(form, "name")}
59+
autoFocus
60+
autoComplete="name"
61+
fullWidth
62+
label={Language.nameLabel}
63+
variant="outlined"
64+
/>
65+
<TextField
66+
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
67+
onChange={onChangeTrimmed(form)}
68+
autoComplete="email"
69+
fullWidth
70+
label={Language.emailLabel}
71+
variant="outlined"
72+
/>
73+
<TextField
74+
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
75+
onChange={onChangeTrimmed(form)}
76+
autoComplete="username"
77+
fullWidth
78+
label={Language.usernameLabel}
79+
variant="outlined"
80+
/>
81+
82+
{error && <FormHelperText error>{error}</FormHelperText>}
83+
84+
<div>
85+
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
86+
{isLoading ? "" : Language.updatePreferences}
87+
</LoadingButton>
88+
</div>
89+
</Stack>
90+
</form>
91+
</>
92+
)
93+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import TextField from "@material-ui/core/TextField"
2+
import { Story } from "@storybook/react"
3+
import React from "react"
4+
import { Stack, StackProps } from "./Stack"
5+
6+
export default {
7+
title: "Components/Stack",
8+
component: Stack,
9+
}
10+
11+
const Template: Story<StackProps> = (args: StackProps) => (
12+
<Stack {...args}>
13+
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
14+
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
15+
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
16+
</Stack>
17+
)
18+
19+
export const Example = Template.bind({})
20+
Example.args = {
21+
spacing: 2,
22+
}

site/src/components/Stack/Stack.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import React from "react"
3+
4+
export interface StackProps {
5+
spacing?: number
6+
}
7+
8+
const useStyles = makeStyles((theme) => ({
9+
stack: {
10+
display: "flex",
11+
flexDirection: "column",
12+
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
13+
},
14+
}))
15+
16+
export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
17+
const styles = useStyles({ spacing })
18+
return <div className={styles.stack}>{children}</div>
19+
}

0 commit comments

Comments
 (0)
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