Skip to content

Commit 4183a4e

Browse files
authored
feat: Initial Login flow (#42)
This just implements a basic sign-in flow, using the new endpoints in #29 : ![2022-01-20 12 35 30](https://user-images.githubusercontent.com/88213859/150418044-85900d1f-8890-4c60-baae-234342de71fa.gif) This brings over several dependencies that are necessary: - `formik` - `yep` Ports over some v1 code to bootstrap it: - `FormTextField` - `PasswordField` - `CoderIcon` And implements basic sign-in: Fixes #37 Fixes #43 This does not implement it navbar integration (importantly - there is no way to sign out yet, unless you manually delete your `session_token`). I'll do that in the next PR - figured this was big enough to get reviewed.
1 parent 7b9347b commit 4183a4e

28 files changed

+1029
-69
lines changed

_jest/setupTests.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Global setup for our Jest tests
3+
*/
4+
5+
// Set up 'next-router-mock' to with our front-end tests:
6+
// https://github.com/scottrippey/next-router-mock#quick-start
7+
jest.mock("next/router", () => require("next-router-mock"))

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module.exports = {
55
displayName: "test",
66
preset: "ts-jest",
77
roots: ["<rootDir>/site"],
8+
setupFilesAfterEnv: ["<rootDir>/_jest/setupTests.ts"],
89
transform: {
910
"^.+\\.tsx?$": "ts-jest",
1011
},
@@ -26,8 +27,10 @@ module.exports = {
2627
"<rootDir>/site/**/*.tsx",
2728
"!<rootDir>/site/**/*.stories.tsx",
2829
"!<rootDir>/site/.next/**/*.*",
30+
"!<rootDir>/site/api.ts",
2931
"!<rootDir>/site/dev.ts",
3032
"!<rootDir>/site/next-env.d.ts",
3133
"!<rootDir>/site/next.config.js",
34+
"!<rootDir>/site/out/**/*.*",
3235
],
3336
}

package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,28 @@
4040
"eslint-plugin-react": "7.28.0",
4141
"eslint-plugin-react-hooks": "4.3.0",
4242
"express": "4.17.2",
43+
"formik": "2.2.9",
4344
"http-proxy-middleware": "2.0.1",
4445
"jest": "27.4.7",
4546
"jest-runner-eslint": "1.0.0",
4647
"next": "12.0.7",
48+
"next-router-mock": "^0.6.5",
4749
"prettier": "2.5.1",
4850
"react": "17.0.2",
4951
"react-dom": "17.0.2",
5052
"sql-formatter": "^4.0.2",
53+
"swr": "1.1.2",
5154
"ts-jest": "27.1.2",
5255
"ts-loader": "9.2.6",
5356
"ts-node": "10.4.0",
54-
"typescript": "4.5.4"
57+
"typescript": "4.5.4",
58+
"yup": "0.32.11"
5559
},
56-
"dependencies": {}
60+
"dependencies": {},
61+
"browserslist": [
62+
"chrome 66",
63+
"firefox 63",
64+
"edge 79",
65+
"safari 13.1"
66+
]
5767
}

site/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
interface LoginResponse {
2+
session_token: string
3+
}
4+
5+
export const login = async (email: string, password: string): Promise<LoginResponse> => {
6+
const response = await fetch("/api/v2/login", {
7+
method: "POST",
8+
headers: {
9+
"Content-Type": "application/json",
10+
},
11+
body: JSON.stringify({
12+
email,
13+
password,
14+
}),
15+
})
16+
17+
const body = await response.json()
18+
if (!response.ok) {
19+
throw new Error(body.message)
20+
}
21+
22+
return body
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { render, screen } from "@testing-library/react"
2+
import React from "react"
3+
import { LoadingButton } from "./LoadingButton"
4+
5+
describe("LoadingButton", () => {
6+
it("renders", async () => {
7+
// When
8+
render(<LoadingButton>Sign In</LoadingButton>)
9+
10+
// Then
11+
const element = await screen.findByText("Sign In")
12+
expect(element).toBeDefined()
13+
})
14+
15+
it("shows spinner if loading is set to true", async () => {
16+
// When
17+
render(<LoadingButton loading>Sign in</LoadingButton>)
18+
19+
// Then
20+
const spinnerElement = await screen.findByRole("progressbar")
21+
expect(spinnerElement).toBeDefined()
22+
})
23+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Button, { ButtonProps } from "@material-ui/core/Button"
2+
import CircularProgress from "@material-ui/core/CircularProgress"
3+
import { makeStyles } from "@material-ui/core/styles"
4+
import * as React from "react"
5+
6+
export interface LoadingButtonProps extends ButtonProps {
7+
/** Whether or not to disable the button and show a spinner */
8+
loading?: boolean
9+
}
10+
11+
/**
12+
* LoadingButton is a small wrapper around Material-UI's button to show a loading spinner
13+
*
14+
* In Material-UI 5+ - this is built-in, but since we're on an earlier version,
15+
* we have to roll our own.
16+
*/
17+
export const LoadingButton: React.FC<LoadingButtonProps> = ({ loading = false, children, ...rest }) => {
18+
const styles = useStyles()
19+
const hidden = loading ? { opacity: 0 } : undefined
20+
21+
return (
22+
<Button {...rest} disabled={rest.disabled || loading}>
23+
<span style={hidden}>{children}</span>
24+
{loading && (
25+
<div className={styles.loader}>
26+
<CircularProgress size={18} className={styles.spinner} />
27+
</div>
28+
)}
29+
</Button>
30+
)
31+
}
32+
33+
const useStyles = makeStyles((theme) => ({
34+
loader: {
35+
position: "absolute",
36+
top: "50%",
37+
left: "50%",
38+
transform: "translate(-50%, -50%)",
39+
height: 18,
40+
width: 18,
41+
},
42+
spinner: {
43+
color: theme.palette.text.disabled,
44+
},
45+
}))

site/components/Button/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export { SplitButton } from "./SplitButton"
1+
export * from "./SplitButton"
2+
export * from "./LoadingButton"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { act, fireEvent, render, screen } from "@testing-library/react"
2+
import { useFormik } from "formik"
3+
import React from "react"
4+
import * as yup from "yup"
5+
import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField"
6+
7+
namespace Helpers {
8+
export interface FormValues {
9+
name: string
10+
}
11+
12+
export const requiredValidationMsg = "required"
13+
14+
const FormTextField = formTextFieldFactory<FormValues>()
15+
16+
export const Component: React.FC<Omit<FormTextFieldProps<FormValues>, "form" | "formFieldName">> = (props) => {
17+
const form = useFormik<FormValues>({
18+
initialValues: {
19+
name: "",
20+
},
21+
onSubmit: (values, helpers) => {
22+
return helpers.setSubmitting(false)
23+
},
24+
validationSchema: yup.object({
25+
name: yup.string().required(requiredValidationMsg),
26+
}),
27+
})
28+
29+
return <FormTextField {...props} form={form} formFieldName="name" />
30+
}
31+
}
32+
33+
describe("FormTextField", () => {
34+
describe("helperText", () => {
35+
it("uses helperText prop when there are no errors", () => {
36+
// Given
37+
const props = {
38+
helperText: "testing",
39+
}
40+
41+
// When
42+
const { queryByText } = render(<Helpers.Component {...props} />)
43+
44+
// Then
45+
expect(queryByText(props.helperText)).toBeDefined()
46+
})
47+
48+
it("uses validation message when there are errors", () => {
49+
// Given
50+
const props = {}
51+
52+
// When
53+
const { container } = render(<Helpers.Component {...props} />)
54+
const el = container.firstChild
55+
56+
// Then
57+
expect(el).toBeDefined()
58+
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull()
59+
60+
// When
61+
act(() => {
62+
fireEvent.focus(el as Element)
63+
})
64+
65+
// Then
66+
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull()
67+
68+
// When
69+
act(() => {
70+
fireEvent.blur(el as Element)
71+
})
72+
73+
// Then
74+
expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeDefined()
75+
})
76+
})
77+
})

0 commit comments

Comments
 (0)