Skip to content

Commit 7e48df8

Browse files
authored
refactor(site): SignInForm without wrapper component (#558)
* Remove wrapper from SignInForm * Spruce up tests * Add util for form props * Add back trim * Add unit tests * Lint, type fixes * Pascal case for language * Arrow functions * Target text in e2e
1 parent f4ac7a3 commit 7e48df8

File tree

7 files changed

+164
-57
lines changed

7 files changed

+164
-57
lines changed

site/e2e/pom/SignInPage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ export class SignInPage extends BasePom {
77
}
88

99
async submitBuiltInAuthentication(email: string, password: string): Promise<void> {
10-
await this.page.fill("id=signin-form-inpt-email", email)
11-
await this.page.fill("id=signin-form-inpt-password", password)
12-
await this.page.click("id=signin-form-submit")
10+
await this.page.fill("text=Email", email)
11+
await this.page.fill("text=Password", password)
12+
await this.page.click("text=Sign In")
1313
}
1414
}

site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@storybook/addon-links": "6.4.19",
4949
"@storybook/react": "6.4.19",
5050
"@testing-library/react": "12.1.4",
51+
"@testing-library/user-event": "^13.5.0",
5152
"@types/express": "4.17.13",
5253
"@types/jest": "27.4.1",
5354
"@types/node": "14.18.12",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { FormikContextType } from "formik/dist/types"
2+
import { getFormHelpers, onChangeTrimmed } from "./index"
3+
4+
interface TestType {
5+
untouchedGoodField: string
6+
untouchedBadField: string
7+
touchedGoodField: string
8+
touchedBadField: string
9+
}
10+
11+
const mockHandleChange = jest.fn()
12+
13+
const form = {
14+
errors: {
15+
untouchedGoodField: undefined,
16+
untouchedBadField: "oops!",
17+
touchedGoodField: undefined,
18+
touchedBadField: "oops!",
19+
},
20+
touched: {
21+
untouchedGoodField: false,
22+
untouchedBadField: false,
23+
touchedGoodField: true,
24+
touchedBadField: true,
25+
},
26+
handleChange: mockHandleChange,
27+
handleBlur: jest.fn(),
28+
getFieldProps: (name: string) => {
29+
return {
30+
name,
31+
onBlur: jest.fn(),
32+
onChange: jest.fn(),
33+
value: "",
34+
}
35+
},
36+
} as unknown as FormikContextType<TestType>
37+
38+
describe("form util functions", () => {
39+
describe("getFormHelpers", () => {
40+
const untouchedGoodResult = getFormHelpers<TestType>(form, "untouchedGoodField")
41+
const untouchedBadResult = getFormHelpers<TestType>(form, "untouchedBadField")
42+
const touchedGoodResult = getFormHelpers<TestType>(form, "touchedGoodField")
43+
const touchedBadResult = getFormHelpers<TestType>(form, "touchedBadField")
44+
it("populates the 'field props'", () => {
45+
expect(untouchedGoodResult.name).toEqual("untouchedGoodField")
46+
expect(untouchedGoodResult.onBlur).toBeDefined()
47+
expect(untouchedGoodResult.onChange).toBeDefined()
48+
expect(untouchedGoodResult.value).toBeDefined()
49+
})
50+
it("sets the id to the name", () => {
51+
expect(untouchedGoodResult.id).toEqual("untouchedGoodField")
52+
})
53+
it("sets error to true if touched and invalid", () => {
54+
expect(untouchedGoodResult.error).toBeFalsy
55+
expect(untouchedBadResult.error).toBeFalsy
56+
expect(touchedGoodResult.error).toBeFalsy
57+
expect(touchedBadResult.error).toBeTruthy
58+
})
59+
it("sets helperText to the error message if touched and invalid", () => {
60+
expect(untouchedGoodResult.helperText).toBeUndefined
61+
expect(untouchedBadResult.helperText).toBeUndefined
62+
expect(touchedGoodResult.helperText).toBeUndefined
63+
expect(touchedBadResult.helperText).toEqual("oops!")
64+
})
65+
})
66+
67+
describe("onChangeTrimmed", () => {
68+
it("calls handleChange with trimmed value", () => {
69+
const event = { target: { value: " hello " } } as React.ChangeEvent<HTMLInputElement>
70+
onChangeTrimmed<TestType>(form)(event)
71+
expect(mockHandleChange).toHaveBeenCalledWith({ target: { value: "hello" } })
72+
})
73+
})
74+
})

site/src/components/Form/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
1+
import { FormikContextType, getIn } from "formik"
2+
import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react"
3+
14
export * from "./FormCloseButton"
25
export * from "./FormSection"
36
export * from "./FormDropdownField"
47
export * from "./FormTextField"
58
export * from "./FormTitle"
9+
10+
interface FormHelpers {
11+
name: string
12+
onBlur: FocusEventHandler
13+
onChange: ChangeEventHandler
14+
id: string
15+
value?: string | number
16+
error: boolean
17+
helperText?: string
18+
}
19+
20+
export const getFormHelpers = <T>(form: FormikContextType<T>, name: string): FormHelpers => {
21+
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
22+
const touched = getIn(form.touched, name)
23+
const errors = getIn(form.errors, name)
24+
return {
25+
...form.getFieldProps(name),
26+
id: name,
27+
error: touched && Boolean(errors),
28+
helperText: touched && errors,
29+
}
30+
}
31+
32+
export const onChangeTrimmed =
33+
<T>(form: FormikContextType<T>) =>
34+
(event: ChangeEvent<HTMLInputElement>): void => {
35+
event.target.value = event.target.value.trim()
36+
form.handleChange(event)
37+
}

site/src/components/SignIn/SignInForm.tsx

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import React from "react"
44
import * as Yup from "yup"
55

66
import { Welcome } from "./Welcome"
7-
import { FormTextField } from "../Form"
87
import FormHelperText from "@material-ui/core/FormHelperText"
98
import { LoadingButton } from "./../Button"
9+
import TextField from "@material-ui/core/TextField"
10+
import { getFormHelpers, onChangeTrimmed } from "../Form"
1011

1112
/**
1213
* BuiltInAuthFormValues describes a form using built-in (email/password)
@@ -18,8 +19,17 @@ interface BuiltInAuthFormValues {
1819
password: string
1920
}
2021

22+
export const Language = {
23+
emailLabel: "Email",
24+
passwordLabel: "Password",
25+
emailInvalid: "Please enter a valid email address.",
26+
emailRequired: "Please enter an email address.",
27+
authErrorMessage: "Incorrect email or password.",
28+
signIn: "Sign In",
29+
}
30+
2131
const validationSchema = Yup.object({
22-
email: Yup.string().required("Email is required."),
32+
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
2333
password: Yup.string(),
2434
})
2535

@@ -59,50 +69,30 @@ export const SignInForm: React.FC<SignInFormProps> = ({ isLoading, authErrorMess
5969
<>
6070
<Welcome />
6171
<form onSubmit={form.handleSubmit}>
62-
<div>
63-
<FormTextField
64-
label="Email"
65-
autoComplete="email"
66-
autoFocus
67-
className={styles.loginTextField}
68-
eventTransform={(email: string) => email.trim()}
69-
form={form}
70-
formFieldName="email"
71-
fullWidth
72-
inputProps={{
73-
id: "signin-form-inpt-email",
74-
}}
75-
variant="outlined"
76-
/>
77-
<FormTextField
78-
label="Password"
79-
autoComplete="current-password"
80-
className={styles.loginTextField}
81-
form={form}
82-
formFieldName="password"
83-
fullWidth
84-
inputProps={{
85-
id: "signin-form-inpt-password",
86-
}}
87-
isPassword
88-
variant="outlined"
89-
/>
90-
{authErrorMessage && (
91-
<FormHelperText data-testid="sign-in-error" error>
92-
{authErrorMessage}
93-
</FormHelperText>
94-
)}
95-
</div>
72+
<TextField
73+
{...getFormHelpers<BuiltInAuthFormValues>(form, "email")}
74+
onChange={onChangeTrimmed(form)}
75+
autoFocus
76+
autoComplete="email"
77+
className={styles.loginTextField}
78+
fullWidth
79+
label={Language.emailLabel}
80+
variant="outlined"
81+
/>
82+
<TextField
83+
{...getFormHelpers<BuiltInAuthFormValues>(form, "password")}
84+
autoComplete="current-password"
85+
className={styles.loginTextField}
86+
fullWidth
87+
id="password"
88+
label={Language.passwordLabel}
89+
type="password"
90+
variant="outlined"
91+
/>
92+
{authErrorMessage && <FormHelperText error>{Language.authErrorMessage}</FormHelperText>}
9693
<div className={styles.submitBtn}>
97-
<LoadingButton
98-
color="primary"
99-
loading={isLoading}
100-
fullWidth
101-
id="signin-form-submit"
102-
type="submit"
103-
variant="contained"
104-
>
105-
{isLoading ? "" : "Sign In"}
94+
<LoadingButton color="primary" loading={isLoading} fullWidth type="submit" variant="contained">
95+
{isLoading ? "" : Language.signIn}
10696
</LoadingButton>
10797
</div>
10898
</form>

site/src/pages/login.test.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import React from "react"
2-
import { act, fireEvent, screen } from "@testing-library/react"
2+
import { act, screen } from "@testing-library/react"
3+
import userEvent from "@testing-library/user-event"
34
import { history, render } from "../test_helpers"
45
import { SignInPage } from "./login"
56
import { server } from "../test_helpers/server"
67
import { rest } from "msw"
8+
import { Language } from "../components/SignIn/SignInForm"
79

810
describe("SignInPage", () => {
911
beforeEach(() => {
@@ -21,12 +23,12 @@ describe("SignInPage", () => {
2123
render(<SignInPage />)
2224

2325
// Then
24-
await screen.findByText("Sign In", { exact: false })
26+
await screen.findByText(Language.signIn, { exact: false })
2527
})
2628

2729
it("shows an error message if SignIn fails", async () => {
2830
// Given
29-
const { container } = render(<SignInPage />)
31+
render(<SignInPage />)
3032
// Make login fail
3133
server.use(
3234
rest.post("/api/v2/users/login", async (req, res, ctx) => {
@@ -35,17 +37,18 @@ describe("SignInPage", () => {
3537
)
3638

3739
// When
38-
// Set username / password
39-
const [username, password] = container.querySelectorAll("input")
40-
fireEvent.change(username, { target: { value: "test@coder.com" } })
41-
fireEvent.change(password, { target: { value: "password" } })
40+
// Set email / password
41+
const email = screen.getByLabelText(Language.emailLabel)
42+
const password = screen.getByLabelText(Language.passwordLabel)
43+
userEvent.type(email, "test@coder.com")
44+
userEvent.type(password, "password")
4245
// Click sign-in
43-
const signInButton = await screen.findByText("Sign In")
46+
const signInButton = await screen.findByText(Language.signIn)
4447
act(() => signInButton.click())
4548

4649
// Then
4750
// Finding error by test id because it comes from the backend
48-
const errorMessage = await screen.findByTestId("sign-in-error")
51+
const errorMessage = await screen.findByText(Language.authErrorMessage)
4952
expect(errorMessage).toBeDefined()
5053
expect(history.location.pathname).toEqual("/login")
5154
})

site/yarn.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2731,6 +2731,13 @@
27312731
"@testing-library/dom" "^8.0.0"
27322732
"@types/react-dom" "*"
27332733

2734+
"@testing-library/user-event@^13.5.0":
2735+
version "13.5.0"
2736+
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295"
2737+
integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==
2738+
dependencies:
2739+
"@babel/runtime" "^7.12.5"
2740+
27342741
"@tootallnate/once@1":
27352742
version "1.1.2"
27362743
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"

0 commit comments

Comments
 (0)