Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: add the preferences/account page#999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
BrunoQuaresma merged 35 commits intomainfrombq/755/account-page
Apr 15, 2022
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
35 commits
Select commitHold shift + click to select a range
19579eb
feat: Add account form
BrunoQuaresmaApr 12, 2022
9ab1f96
chore: Merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresmaApr 12, 2022
a23cc88
Merge branch 'main' of github.com:coder/coder into bq/755/account-page
BrunoQuaresmaApr 12, 2022
f9f1c5a
feat: Add account form
BrunoQuaresmaApr 13, 2022
1bbaf8a
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresmaApr 13, 2022
fa1d0e6
feat: show notification when preferences are updated
BrunoQuaresmaApr 13, 2022
1278ed6
test: account form submission with success
BrunoQuaresmaApr 13, 2022
7ccf811
chore: remove unecessary timeout
BrunoQuaresmaApr 13, 2022
2ae0987
test: add tests
BrunoQuaresmaApr 13, 2022
4595186
style: fix message copy
BrunoQuaresmaApr 14, 2022
fa91276
style: improve success message
BrunoQuaresmaApr 14, 2022
fc01ff8
refactor: name is not optional
BrunoQuaresmaApr 14, 2022
f3fedd0
chore: move renderWithAuth to test_hepers/index.tsx
BrunoQuaresmaApr 14, 2022
807d4e9
chore: move error types and utils to api/errors.ts
BrunoQuaresmaApr 14, 2022
fa580c7
test: use userEvent
BrunoQuaresmaApr 14, 2022
3d76331
fix: remove async from onSubmit
BrunoQuaresmaApr 14, 2022
37bc235
refactor: improve error types
BrunoQuaresmaApr 14, 2022
17a0b16
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresmaApr 14, 2022
0e8ac63
chore: merge branch 'bq/755/account-page' of github.com:coder/coder i…
BrunoQuaresmaApr 14, 2022
12058f8
refactor: api errors
BrunoQuaresmaApr 14, 2022
e489210
refactor: move UPDATE_PROFILE to idle state
BrunoQuaresmaApr 14, 2022
8098628
refactor: change FormStack to Stack and add storybook
BrunoQuaresmaApr 14, 2022
1f23e30
fix: error handling and tests
BrunoQuaresmaApr 14, 2022
a0588d1
feat: handle unknown error
BrunoQuaresmaApr 14, 2022
b3159d0
fix: make the eslint-disable inline
BrunoQuaresmaApr 14, 2022
e07d717
chore: rename story
BrunoQuaresmaApr 14, 2022
4d7da77
chore: merge branch 'bq/755/account-page' of github.com:coder/coder i…
BrunoQuaresmaApr 14, 2022
8d63848
Update site/src/xServices/auth/authXService.ts
BrunoQuaresmaApr 14, 2022
a11ff10
Update site/src/pages/preferences/account.tsx
BrunoQuaresmaApr 14, 2022
bde7c15
Fix errors
BrunoQuaresmaApr 15, 2022
684b902
chore: merge branch 'main' of github.com:coder/coder into bq/755/acco…
BrunoQuaresmaApr 15, 2022
bbf2152
Fix type
BrunoQuaresmaApr 15, 2022
7f32600
Fix forms
BrunoQuaresmaApr 15, 2022
eb65490
Normalize machine
BrunoQuaresmaApr 15, 2022
59bac76
Fix: tests
BrunoQuaresmaApr 15, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletionsite/jest.setup.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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())
Expand Down
38 changes: 38 additions & 0 deletionssite/src/api/errors.test.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { isApiError, mapApiErrorToFieldErrors } from "./errors"

describe("isApiError", () => {
it("returns true when the object is an API Error", () => {
expect(
isApiError({
isAxiosError: true,
response: {
data: {
message: "Invalid entry",
errors: [{ detail: "Username is already in use", field: "username" }],
},
},
}),
).toBe(true)
})

it("returns false when the object is Error", () => {
expect(isApiError(new Error())).toBe(false)
})

it("returns false when the object is undefined", () => {
expect(isApiError(undefined)).toBe(false)
})
})

describe("mapApiErrorToFieldErrors", () => {
it("returns correct field errors", () => {
expect(
mapApiErrorToFieldErrors({
message: "Invalid entry",
errors: [{ detail: "Username is already in use", field: "username" }],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I love having these tests because it helps me see what the data structure looks like :)

BrunoQuaresma and greyscaled reacted with heart emoji
}),
).toEqual({
username: "Username is already in use",
})
})
})
46 changes: 46 additions & 0 deletionssite/src/api/errors.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import axios, { AxiosError, AxiosResponse } from "axios"

export const Language = {
errorsByCode: {
defaultErrorCode: "Invalid value",
},
}

interface FieldError {
field: string
detail: string
}

type FieldErrors = Record<FieldError["field"], FieldError["detail"]>

export interface ApiErrorResponse {
message: string
errors?: FieldError[]
}

export type ApiError = AxiosError<ApiErrorResponse> & { response: AxiosResponse<ApiErrorResponse> }

// 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
}
7 changes: 6 additions & 1 deletionsite/src/api/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -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 = {
Expand DownExpand Up@@ -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 changes: 7 additions & 0 deletionssite/src/api/types.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ export interface UserResponse {
readonly username: string
readonly email: string
readonly created_at: string
readonly name: string
}

/**
Expand DownExpand Up@@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
export interface WorkspaceAutostopRequest {
schedule: string
}

export interface UpdateProfileRequest {
readonly username: string
readonly email: string
readonly name: string
}
4 changes: 2 additions & 2 deletionssite/src/components/Form/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
2 changes: 1 addition & 1 deletionsite/src/components/Page/RequireAuth.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 />
Expand Down
93 changes: 93 additions & 0 deletionssite/src/components/Preferences/AccountForm.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../Form"
import { Stack } from "../Stack/Stack"
import { LoadingButton } from "./../Button"

interface AccountFormValues {
name: string
email: string
username: string
}

export const Language = {
nameLabel: "Name",
usernameLabel: "Username",
emailLabel: "Email",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
updatePreferences: "Update preferences",
}

const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
name: Yup.string().optional(),
username: Yup.string().trim(),
})

export type AccountFormErrors = FormikErrors<AccountFormValues>
export interface AccountFormProps {
isLoading: boolean
initialValues: AccountFormValues
onSubmit: (values: AccountFormValues) => void
formErrors?: AccountFormErrors
error?: string
}

export const AccountForm: React.FC<AccountFormProps> = ({
isLoading,
onSubmit,
initialValues,
formErrors = {},
error,
}) => {
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
initialValues,
validationSchema,
onSubmit,
})

return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFormHelpers<AccountFormValues>(form, "name")}
autoFocus
autoComplete="name"
fullWidth
label={Language.nameLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "email", formErrors.email)}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFormHelpers<AccountFormValues>(form, "username", formErrors.username)}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>

{error && <FormHelperText error>{error}</FormHelperText>}

<div>
<LoadingButton color="primary" loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePreferences}
</LoadingButton>
</div>
</Stack>
</form>
</>
)
}
22 changes: 22 additions & 0 deletionssite/src/components/Stack/Stack.stories.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import TextField from "@material-ui/core/TextField"
import { Story } from "@storybook/react"
import React from "react"
import { Stack, StackProps } from "./Stack"

export default {
title: "Components/Stack",
component: Stack,
}

const Template: Story<StackProps> = (args: StackProps) => (
<Stack {...args}>
<TextField autoFocus autoComplete="name" fullWidth label="Name" variant="outlined" />
<TextField autoComplete="email" fullWidth label="Email" variant="outlined" />
<TextField autoComplete="username" fullWidth label="Username" variant="outlined" />
</Stack>
)

export const Example = Template.bind({})
Example.args = {
spacing: 2,
}
19 changes: 19 additions & 0 deletionssite/src/components/Stack/Stack.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"

export interface StackProps {
spacing?: number
}

const useStyles = makeStyles((theme) => ({
stack: {
display: "flex",
flexDirection: "column",
gap: ({ spacing }: { spacing: number }) => theme.spacing(spacing),
},
}))

export const Stack: React.FC<StackProps> = ({ children, spacing = 2 }) => {
const styles = useStyles({ spacing })
return <div className={styles.stack}>{children}</div>
}
106 changes: 106 additions & 0 deletionssite/src/pages/preferences/account.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import * as AccountForm from "../../components/Preferences/AccountForm"
import { GlobalSnackbar } from "../../components/Snackbar/GlobalSnackbar"
import { renderWithAuth } from "../../test_helpers"
import * as AuthXService from "../../xServices/auth/authXService"
import { Language, PreferencesAccountPage } from "./account"

const renderPage = () => {
return renderWithAuth(
<>
<PreferencesAccountPage />
<GlobalSnackbar />
</>,
)
}

const newData = {
name: "User",
email: "user@coder.com",
username: "user",
}

const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Name"))
fireEvent.change(screen.getByLabelText("Name"), { target: { value: newData.name } })
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } })
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } })
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)
})
})
})
Loading

[8]ページ先頭

©2009-2025 Movatter.jp