From 7b0488cdd4e6f5071af8ff00ad108d90127557f8 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 18:40:58 +0000 Subject: [PATCH 01/37] Start porting over some SignIn components --- site/components/SignIn/SignInForm.tsx | 152 ++++++++++++++++++++++++++ site/components/SignIn/Welcome.tsx | 42 +++++++ site/components/SignIn/index.tsx | 1 + site/pages/login.tsx | 0 4 files changed, 195 insertions(+) create mode 100644 site/components/SignIn/SignInForm.tsx create mode 100644 site/components/SignIn/Welcome.tsx create mode 100644 site/components/SignIn/index.tsx create mode 100644 site/pages/login.tsx diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx new file mode 100644 index 0000000000000..1baec925258c5 --- /dev/null +++ b/site/components/SignIn/SignInForm.tsx @@ -0,0 +1,152 @@ +import { makeStyles, useTheme } from "@material-ui/core/styles" +import { FormikContextType, useFormik } from "formik" + +import { useRouter } from "next/router" +import { Button } from "../Button" +import { formTextFieldFactory } from "../Form" +import { useUserContext } from "product/coder/site/src/contexts" +import React from "react" +import * as Yup from "yup" +import { Welcome } from "./Welcome" + +/** + * BuiltInAuthFormValues describes a form using built-in (email/password) + * authentication. This form may not always be present depending on external + * auth providers available and administrative configurations + */ +interface BuiltInAuthFormValues { + email: string + password: string +} + +const validationSchema = Yup.object({ + email: Yup.string().required("Email is required."), + password: Yup.string(), +}) + +const FormTextField = formTextFieldFactory() + +const useStyles = makeStyles((theme) => ({ + loginBtnWrapper: { + marginTop: theme.spacing(6), + borderTop: `1px solid ${theme.palette.action.disabled}`, + paddingTop: theme.spacing(3), + }, + loginTypeToggleWrapper: { + marginTop: theme.spacing(2), + display: "flex", + justifyContent: "center", + }, + loginTypeToggleBtn: { + color: theme.palette.text.primary, + // We want opacity so that this isn't super highlighted for the user. + // In most cases, they shouldn't want to switch login types. + opacity: 0.5, + "&:hover": { + cursor: "pointer", + opacity: 1, + textDecoration: "underline", + }, + }, + loginTypeToggleBtnFocusVisible: { + opacity: 1, + textDecoration: "underline", + }, + loginTypeBtn: { + backgroundColor: "#2A2B45", + textTransform: "none", + + "&:not(:first-child)": { + marginTop: theme.spacing(2), + }, + }, + submitBtn: { + marginTop: theme.spacing(2), + }, +})) + +export const SignInForm: React.FC = () => { + const router = useRouter() + const { setUser } = useUserContext() + const styles = useStyles() + + const form: FormikContextType = useFormik({ + initialValues: { + email: "", + password: "", + }, + validationSchema, + onSubmit: async ({ email, password }, helpers) => { + const res = await loginWithBasicAuth(null, { email, password }) + if (res.error) { + return helpers.setFieldError("password", res.error.msg) + } + + const userRes = await fetchPersonalUser() + if (userRes.error) { + return helpers.setFieldError("password", userRes.error.msg) + } + setUser(userRes.body) + }, + }) + + /** + * renderSignInForm returns the form for the currently selected + * authentication provider. + */ + const renderSignInForm = () => { + return ( +
+
+ email.trim()} + form={form} + formFieldName="email" + fullWidth + inputProps={{ + id: "signin-form-inpt-email", + }} + margin="none" + placeholder="Email" + variant="outlined" + /> + +
+
+ +
+
+ ) + } + + return ( + <> + + {renderSignInForm()} + + ) +} \ No newline at end of file diff --git a/site/components/SignIn/Welcome.tsx b/site/components/SignIn/Welcome.tsx new file mode 100644 index 0000000000000..094e6231273f7 --- /dev/null +++ b/site/components/SignIn/Welcome.tsx @@ -0,0 +1,42 @@ +import { makeStyles } from "@material-ui/core/styles" +import { CoderIcon } from "../Icons/Coder" +import React from "react" +import Typography from "@material-ui/core/Typography" + +export const Welcome: React.FC = () => { + const styles = useStyles() + + return ( +
+
+ +
+ + <> + Welcome to +
+ Coder + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + logoBox: { + display: "flex", + justifyContent: "center", + }, + logo: { + width: 80, + height: 56, + color: theme.palette.text.primary, + }, + title: { + fontSize: 24, + letterSpacing: -0.3, + marginBottom: theme.spacing(3), + marginTop: theme.spacing(6), + textAlign: "center", + }, +})) \ No newline at end of file diff --git a/site/components/SignIn/index.tsx b/site/components/SignIn/index.tsx new file mode 100644 index 0000000000000..a6bff12e9bbd2 --- /dev/null +++ b/site/components/SignIn/index.tsx @@ -0,0 +1 @@ +export * as "./SignInForm" \ No newline at end of file diff --git a/site/pages/login.tsx b/site/pages/login.tsx new file mode 100644 index 0000000000000..e69de29bb2d1d From 747d2b27c94617bfaff86246453e5443cbe7129a Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 18:41:08 +0000 Subject: [PATCH 02/37] Add formik --- package.json | 1 + yarn.lock | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5bdfc8f78f74f..9318430736ad6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "17.0.11", "@types/superagent": "4.1.14", "express": "4.17.2", + "formik": "2.2.9", "http-proxy-middleware": "2.0.1", "jest": "27.4.7", "next": "12.0.7", diff --git a/yarn.lock b/yarn.lock index da1428d6e0c1d..6ea1f920ea9ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1780,6 +1780,11 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +deepmerge@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170" + integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA== + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -2176,6 +2181,19 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formik@2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -2344,7 +2362,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -3305,6 +3323,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -3315,7 +3338,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.7.0: +lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3930,6 +3953,11 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + react-is@17.0.2, "react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -4517,6 +4545,11 @@ ts-node@10.4.0: make-error "^1.1.1" yn "3.1.1" +tslib@^1.10.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + tty-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" From 0f3fcd6efcbfb0c231eb68a86f51a45a60585bea Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 18:45:01 +0000 Subject: [PATCH 03/37] Add yup --- package.json | 3 ++- yarn.lock | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9318430736ad6..412d215d23f1b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "ts-jest": "27.1.2", "ts-loader": "9.2.6", "ts-node": "10.4.0", - "typescript": "4.5.4" + "typescript": "4.5.4", + "yup": "0.32.11" }, "dependencies": {} } diff --git a/yarn.lock b/yarn.lock index 6ea1f920ea9ab..ac5e0ba69a954 100644 --- a/yarn.lock +++ b/yarn.lock @@ -269,7 +269,7 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== @@ -897,6 +897,11 @@ jest-diff "^27.0.0" pretty-format "^27.0.0" +"@types/lodash@^4.14.175": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -3485,6 +3490,11 @@ ms@2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@^3.1.23: version "3.1.30" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" @@ -3857,6 +3867,11 @@ prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.13.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -4480,6 +4495,11 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -4855,3 +4875,16 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +yup@0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2" From fbd079bbd91a2f4fe0790fe891faf86c1e706e08 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 19:27:24 +0000 Subject: [PATCH 04/37] Initial API scaffolding --- site/api.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 site/api.ts diff --git a/site/api.ts b/site/api.ts new file mode 100644 index 0000000000000..8384abf878609 --- /dev/null +++ b/site/api.ts @@ -0,0 +1,36 @@ + +interface LoginResponse { + 'session_token': string +} + +export const login = async (email: string, password: string): Promise => { + const response = await fetch("/api/v2/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + password, + }) + }) + + return await response.json() +} + +export interface User { + id: string + email: string + created_at: string + username: string +} + +export namespace User { + export const current = async (): Promise => { + const response = await fetch("/api/v2/user", { + method: "GET", + }) + + return await response.json() + } +} \ No newline at end of file From 2c1adc4ea7ad98714dde6dfc5eeeb832622d3de1 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 19:27:34 +0000 Subject: [PATCH 05/37] Port over necessary form components --- site/components/Form/FormTextField.test.tsx | 77 ++++++++++ site/components/Form/FormTextField.tsx | 152 ++++++++++++++++++++ site/components/Form/PasswordField.tsx | 40 ++++++ site/components/Form/types.ts | 16 +++ site/components/SignIn/SignInForm.tsx | 125 ++++++++-------- 5 files changed, 343 insertions(+), 67 deletions(-) create mode 100644 site/components/Form/FormTextField.test.tsx create mode 100644 site/components/Form/FormTextField.tsx create mode 100644 site/components/Form/PasswordField.tsx create mode 100644 site/components/Form/types.ts diff --git a/site/components/Form/FormTextField.test.tsx b/site/components/Form/FormTextField.test.tsx new file mode 100644 index 0000000000000..87653463c68ae --- /dev/null +++ b/site/components/Form/FormTextField.test.tsx @@ -0,0 +1,77 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { useFormik } from "formik" +import React from "react" +import * as yup from "yup" +import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField" + +namespace Helpers { + export interface FormValues { + name: string + } + + export const requiredValidationMsg = "required" + + const FormTextField = formTextFieldFactory() + + export const Component: React.FC, "form" | "formFieldName">> = (props) => { + const form = useFormik({ + initialValues: { + name: "", + }, + onSubmit: (values, helpers) => { + return helpers.setSubmitting(false) + }, + validationSchema: yup.object({ + name: yup.string().required(requiredValidationMsg), + }), + }) + + return + } +} + +describe("FormTextField", () => { + describe("helperText", () => { + it("uses helperText prop when there are no errors", () => { + // Given + const props = { + helperText: "testing", + } + + // When + const { queryByText } = render() + + // Then + expect(queryByText(props.helperText)).toBeDefined() + }) + + it("uses validation message when there are errors", () => { + // Given + const props = {} + + // When + const { container } = render() + const el = container.firstChild + + // Then + expect(el).toBeDefined() + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.focus(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeNull() + + // When + act(() => { + fireEvent.blur(el as Element) + }) + + // Then + expect(screen.queryByText(Helpers.requiredValidationMsg)).toBeDefined() + }) + }) +}) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx new file mode 100644 index 0000000000000..3ab00d8fcb66c --- /dev/null +++ b/site/components/Form/FormTextField.tsx @@ -0,0 +1,152 @@ +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import React from "react" +import { PasswordField } from "./PasswordField" +import { FormFieldProps } from "./types" + +/** + * FormTextFieldProps extends form-related MUI TextFieldProps with Formik + * props. The passed in form is used to compute error states and configure + * change handlers. `formFieldName` represents the key of a Formik value + * that's associated to this component. + */ +export interface FormTextFieldProps + extends Pick< + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + | "variant" + >, + FormFieldProps { + /** + * eventTransform is an optional transformer on the event data before it is + * processed by formik. + * + * @example + * { + * return str.replace(" ", "-") + * }} + * /> + */ + eventTransform?: (value: string) => unknown + /** + * isPassword uses a PasswordField component when `true`; otherwise a + * TextField component is used. + */ + isPassword?: boolean + /** + * displayValueOverride allows displaying a different value in the field + * without changing the actual underlying value. + */ + displayValueOverride?: string +} + +/** + * Factory function for creating a formik TextField + * + * @example + * interface FormValues { + * username: string + * } + * + * // Use the factory to create a FormTextField associated to this form + * const FormTextField = formTextFieldFactory() + * + * const MyComponent: React.FC = () => { + * const form = useFormik() + * + * return ( + * + * ) + * } + */ +export const formTextFieldFactory = (): React.FC> => { + const component: React.FC> = ({ + children, + disabled, + displayValueOverride, + eventTransform, + form, + formFieldName, + helperText, + isPassword = false, + InputProps, + onChange, + type, + ...rest + }) => { + const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) + + // Conversion to a string primitive is necessary as formFieldName is an in + // indexable type such as a string, number or enum. + const fieldId = String(formFieldName) + + const Component = isPassword ? PasswordField : TextField + const inputType = isPassword ? undefined : type + + return ( + { + if (typeof onChange !== "undefined") { + onChange(e) + } + + const event = e + if (typeof eventTransform !== "undefined") { + // TODO(Grey): Asserting the type as a string here is not quite + // right in that when an input is of type="number", the value will + // be a number. Type asserting is better than conversion for this + // reason, but perhaps there's a better way to do this without any + // assertions. + event.target.value = eventTransform(e.target.value) as string + } + form.handleChange(event) + }} + type={inputType} + value={displayValueOverride || form.values[formFieldName]} + > + {children} + + ) + } + + // Required when using an anonymous factory function + component.displayName = "FormTextField" + return component +} diff --git a/site/components/Form/PasswordField.tsx b/site/components/Form/PasswordField.tsx new file mode 100644 index 0000000000000..462c1643185b0 --- /dev/null +++ b/site/components/Form/PasswordField.tsx @@ -0,0 +1,40 @@ +import IconButton from "@material-ui/core/IconButton" +import InputAdornment from "@material-ui/core/InputAdornment" +import { makeStyles } from "@material-ui/core/styles" +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined" +import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined" +import React, { useCallback, useState } from "react" + +type PasswordFieldProps = Omit + +export const PasswordField: React.FC = ({ variant = "outlined", ...rest }) => { + const styles = useStyles() + const [showPassword, setShowPassword] = useState(false) + + const handleVisibilityChange = useCallback(() => setShowPassword((showPassword) => !showPassword), []) + const VisibilityIcon = showPassword ? VisibilityOffOutlined : VisibilityOutlined + + return ( + + + + + + ), + }} + /> + ) +} + +const useStyles = makeStyles({ + visibilityIcon: { + fontSize: 20, + }, +}) diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts new file mode 100644 index 0000000000000..cdaeac947ced7 --- /dev/null +++ b/site/components/Form/types.ts @@ -0,0 +1,16 @@ +import { FormikContextType } from "formik"; + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikContextType + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index 1baec925258c5..dc8f599d4cb49 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -1,10 +1,10 @@ import { makeStyles, useTheme } from "@material-ui/core/styles" import { FormikContextType, useFormik } from "formik" - import { useRouter } from "next/router" + +import * as API from "./../../api" import { Button } from "../Button" import { formTextFieldFactory } from "../Form" -import { useUserContext } from "product/coder/site/src/contexts" import React from "react" import * as Yup from "yup" import { Welcome } from "./Welcome" @@ -67,9 +67,13 @@ const useStyles = makeStyles((theme) => ({ export const SignInForm: React.FC = () => { const router = useRouter() - const { setUser } = useUserContext() + //const { setUser } = useUserContext() const styles = useStyles() + const setUser = (user: API.User) => { + alert("USER: " + JSON.stringify(user)) + } + const form: FormikContextType = useFormik({ initialValues: { email: "", @@ -77,76 +81,63 @@ export const SignInForm: React.FC = () => { }, validationSchema, onSubmit: async ({ email, password }, helpers) => { - const res = await loginWithBasicAuth(null, { email, password }) - if (res.error) { - return helpers.setFieldError("password", res.error.msg) + try { + const _response = await API.login(email, password) + const user = await API.User.current() + setUser(user) + } catch (err) { + helpers.setFieldError("password", err) } - - const userRes = await fetchPersonalUser() - if (userRes.error) { - return helpers.setFieldError("password", userRes.error.msg) - } - setUser(userRes.body) }, }) - /** - * renderSignInForm returns the form for the currently selected - * authentication provider. - */ - const renderSignInForm = () => { - return ( -
-
- email.trim()} - form={form} - formFieldName="email" - fullWidth - inputProps={{ - id: "signin-form-inpt-email", - }} - margin="none" - placeholder="Email" - variant="outlined" - /> - -
-
- -
-
- ) - } - return ( <> - - {renderSignInForm()} + +
+
+ email.trim()} + form={form} + formFieldName="email" + fullWidth + inputProps={{ + id: "signin-form-inpt-email", + }} + margin="none" + placeholder="Email" + variant="outlined" + /> + +
+
+ +
+
) } \ No newline at end of file From e58c824f10f4ef69812e30d5470abf1a9835a588 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 20:25:33 +0000 Subject: [PATCH 06/37] Implement sign-in flow --- site/api.ts | 14 ++++--- site/components/Form/FormTextField.tsx | 51 +++++++++++++------------- site/components/Form/index.tsx | 1 + site/components/Form/types.ts | 2 +- site/components/Icons/CoderIcon.tsx | 17 +++++++++ site/components/Icons/index.ts | 1 + site/components/Navbar/index.tsx | 1 + site/components/Page/Footer.tsx | 1 + site/components/SignIn/SignInForm.tsx | 15 ++------ site/components/SignIn/Welcome.tsx | 4 +- site/components/SignIn/index.tsx | 2 +- site/pages/_app.tsx | 41 ++------------------- site/pages/index.tsx | 12 +++++- site/pages/login.tsx | 30 +++++++++++++++ 14 files changed, 107 insertions(+), 85 deletions(-) create mode 100644 site/components/Form/index.tsx create mode 100644 site/components/Icons/CoderIcon.tsx diff --git a/site/api.ts b/site/api.ts index 8384abf878609..3ab534279dc32 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,6 +1,5 @@ - interface LoginResponse { - 'session_token': string + session_token: string } export const login = async (email: string, password: string): Promise => { @@ -12,10 +11,15 @@ export const login = async (email: string, password: string): Promise extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - | "variant" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. diff --git a/site/components/Form/index.tsx b/site/components/Form/index.tsx new file mode 100644 index 0000000000000..4e916cdae4d1d --- /dev/null +++ b/site/components/Form/index.tsx @@ -0,0 +1 @@ +export * from "./FormTextField" diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts index cdaeac947ced7..dbb786a85db88 100644 --- a/site/components/Form/types.ts +++ b/site/components/Form/types.ts @@ -1,4 +1,4 @@ -import { FormikContextType } from "formik"; +import { FormikContextType } from "formik" /** * FormFieldProps are required props for creating form fields using a factory. diff --git a/site/components/Icons/CoderIcon.tsx b/site/components/Icons/CoderIcon.tsx new file mode 100644 index 0000000000000..9a391d889d5e7 --- /dev/null +++ b/site/components/Icons/CoderIcon.tsx @@ -0,0 +1,17 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" +import React from "react" + +/** + * CoderIcon represents the cloud with brackets Coder brand icon. It does not + * contain additional aspects, like the word 'Coder'. + */ +export const CoderIcon = (props: SvgIconProps): JSX.Element => ( + + + + + + + + +) diff --git a/site/components/Icons/index.ts b/site/components/Icons/index.ts index fa15f30a7d3b4..c135b8db26e79 100644 --- a/site/components/Icons/index.ts +++ b/site/components/Icons/index.ts @@ -1,2 +1,3 @@ +export { CoderIcon } from "./CoderIcon" export { Logo } from "./Logo" export { WorkspacesIcon } from "./WorkspacesIcon" diff --git a/site/components/Navbar/index.tsx b/site/components/Navbar/index.tsx index 3e3ae6bf8428b..172405a5650f6 100644 --- a/site/components/Navbar/index.tsx +++ b/site/components/Navbar/index.tsx @@ -33,6 +33,7 @@ const useStyles = makeStyles((theme) => ({ root: { position: "relative", display: "flex", + flex: "0", flexDirection: "row", justifyContent: "center", alignItems: "center", diff --git a/site/components/Page/Footer.tsx b/site/components/Page/Footer.tsx index 390ccdc477ccb..b0f1e4b225439 100644 --- a/site/components/Page/Footer.tsx +++ b/site/components/Page/Footer.tsx @@ -26,6 +26,7 @@ const useFooterStyles = makeStyles((theme) => ({ root: { textAlign: "center", marginBottom: theme.spacing(5), + flex: "0", }, copyRight: { backgroundColor: theme.palette.background.default, diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index dc8f599d4cb49..b4da46ddcc93a 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -3,11 +3,11 @@ import { FormikContextType, useFormik } from "formik" import { useRouter } from "next/router" import * as API from "./../../api" -import { Button } from "../Button" import { formTextFieldFactory } from "../Form" import React from "react" import * as Yup from "yup" import { Welcome } from "./Welcome" +import Button from "@material-ui/core/Button" /** * BuiltInAuthFormValues describes a form using built-in (email/password) @@ -67,13 +67,8 @@ const useStyles = makeStyles((theme) => ({ export const SignInForm: React.FC = () => { const router = useRouter() - //const { setUser } = useUserContext() const styles = useStyles() - const setUser = (user: API.User) => { - alert("USER: " + JSON.stringify(user)) - } - const form: FormikContextType = useFormik({ initialValues: { email: "", @@ -83,10 +78,9 @@ export const SignInForm: React.FC = () => { onSubmit: async ({ email, password }, helpers) => { try { const _response = await API.login(email, password) - const user = await API.User.current() - setUser(user) + router.push("/") } catch (err) { - helpers.setFieldError("password", err) + helpers.setFieldError("password", "The username or password is incorrect.") } }, }) @@ -130,7 +124,6 @@ export const SignInForm: React.FC = () => { disabled={form.isSubmitting} fullWidth id="signin-form-submit" - loading={form.isSubmitting} type="submit" variant="contained" > @@ -140,4 +133,4 @@ export const SignInForm: React.FC = () => { ) -} \ No newline at end of file +} diff --git a/site/components/SignIn/Welcome.tsx b/site/components/SignIn/Welcome.tsx index 094e6231273f7..b208c80e160b0 100644 --- a/site/components/SignIn/Welcome.tsx +++ b/site/components/SignIn/Welcome.tsx @@ -1,5 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" -import { CoderIcon } from "../Icons/Coder" +import { CoderIcon } from "../Icons" import React from "react" import Typography from "@material-ui/core/Typography" @@ -39,4 +39,4 @@ const useStyles = makeStyles((theme) => ({ marginTop: theme.spacing(6), textAlign: "center", }, -})) \ No newline at end of file +})) diff --git a/site/components/SignIn/index.tsx b/site/components/SignIn/index.tsx index a6bff12e9bbd2..6ea6f3de7dd2a 100644 --- a/site/components/SignIn/index.tsx +++ b/site/components/SignIn/index.tsx @@ -1 +1 @@ -export * as "./SignInForm" \ No newline at end of file +export * from "./SignInForm" diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index f57ad10d6c774..04014f3eb2c8a 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -3,42 +3,9 @@ import React from "react" import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" -import { dark } from "../theme" +import { light } from "../theme" import { AppProps } from "next/app" import { makeStyles } from "@material-ui/core" -import { Navbar } from "../components/Navbar" -import { Footer } from "../components/Page" - -/** - * `Contents` is the wrapper around the core app UI, - * containing common UI elements like the footer and navbar. - * - * This can't be inlined in `MyApp` because it requires styling, - * and `useStyles` needs to be inside a `` - */ -const Contents: React.FC = ({ Component, pageProps }) => { - const styles = useStyles() - - const header = ( -
- -
- ) - - const footer = ( -
-
-
- ) - - return ( -
- {header} - - {footer} -
- ) -} /** * ClientRender is a component that only allows its children to be rendered @@ -53,12 +20,12 @@ const ClientRender: React.FC = ({ children }) => ( * is the root rendering logic of the application - setting up our router * and any contexts / global state management. */ -const MyApp: React.FC = (appProps) => { +const MyApp: React.FC = ({ Component, pageProps }) => { return ( - + - + ) diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 3654e69b52d90..4818936eeb386 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -3,6 +3,8 @@ import { makeStyles, Box, Paper } from "@material-ui/core" import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" import { EmptyState, SplitButton } from "../components" +import { Navbar } from "../components/Navbar" +import { Footer } from "../components/Page" const WorkspacesPage: React.FC = () => { const styles = useStyles() @@ -17,7 +19,8 @@ const WorkspacesPage: React.FC = () => { } return ( - <> +
+
color="primary" @@ -42,11 +45,16 @@ const WorkspacesPage: React.FC = () => { - +
+
) } const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + }, header: { display: "flex", flexDirection: "row-reverse", diff --git a/site/pages/login.tsx b/site/pages/login.tsx index e69de29bb2d1d..c8103fe811bf9 100644 --- a/site/pages/login.tsx +++ b/site/pages/login.tsx @@ -0,0 +1,30 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" +import { SignInForm } from "./../components/SignIn" + +export const useStyles = makeStyles((theme) => ({ + root: { + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + }, + container: { + marginTop: theme.spacing(-8), + minWidth: "320px", + maxWidth: "320px", + }, +})) + +export const SignInPage: React.FC = () => { + const styles = useStyles() + return ( +
+
+ +
+
+ ) +} + +export default SignInPage From e2e5a6fb5535d1e90de9e1af355f9e5c360d0595 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 20:33:58 +0000 Subject: [PATCH 07/37] Remove unnecessary return type from login --- site/components/SignIn/SignInForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index b4da46ddcc93a..6dadce1479ea0 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -77,7 +77,7 @@ export const SignInForm: React.FC = () => { validationSchema, onSubmit: async ({ email, password }, helpers) => { try { - const _response = await API.login(email, password) + await API.login(email, password) router.push("/") } catch (err) { helpers.setFieldError("password", "The username or password is incorrect.") From 891261dc55632159b9d0d5596f8e3fcfbd18cee7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 20:54:40 +0000 Subject: [PATCH 08/37] Add AuthenticatedRouter helper --- site/api.ts | 10 +++- .../Routing/AuthenticatedRouter.tsx | 27 +++++++++ site/components/Routing/index.tsx | 1 + site/pages/index.tsx | 57 ++++++++++--------- 4 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 site/components/Routing/AuthenticatedRouter.tsx create mode 100644 site/components/Routing/index.tsx diff --git a/site/api.ts b/site/api.ts index 3ab534279dc32..8691b6a4fb623 100644 --- a/site/api.ts +++ b/site/api.ts @@ -15,7 +15,7 @@ export const login = async (email: string, password: string): Promise { + const [isAuthenticated, setAuthenticated] = useState(false) + const router = useRouter() + + useEffect(() => { + const asyncFn = async () => { + try { + await API.User.current() + setAuthenticated(true) + } catch (ex) { + router.push("/login") + } + } + + asyncFn() + }, []) + + if (!isAuthenticated) { + return
loading
+ } + return <>{children} +} diff --git a/site/components/Routing/index.tsx b/site/components/Routing/index.tsx new file mode 100644 index 0000000000000..284e48ab8a629 --- /dev/null +++ b/site/components/Routing/index.tsx @@ -0,0 +1 @@ +export * from "./AuthenticatedRouter" diff --git a/site/pages/index.tsx b/site/pages/index.tsx index 4818936eeb386..0ac438980b22d 100644 --- a/site/pages/index.tsx +++ b/site/pages/index.tsx @@ -5,6 +5,7 @@ import { AddToQueue as AddWorkspaceIcon } from "@material-ui/icons" import { EmptyState, SplitButton } from "../components" import { Navbar } from "../components/Navbar" import { Footer } from "../components/Page" +import { AuthenticatedRouter } from "../components/Routing" const WorkspacesPage: React.FC = () => { const styles = useStyles() @@ -19,34 +20,36 @@ const WorkspacesPage: React.FC = () => { } return ( -
- -
- - color="primary" - onClick={createWorkspace} - options={[ - { - label: "New workspace", - value: "custom", - }, - { - label: "New workspace from template", - value: "template", - }, - ]} - startIcon={} - textTransform="none" - /> -
+ +
+ +
+ + color="primary" + onClick={createWorkspace} + options={[ + { + label: "New workspace", + value: "custom", + }, + { + label: "New workspace from template", + value: "template", + }, + ]} + startIcon={} + textTransform="none" + /> +
- - - - - -
-
+ + + + + +
+
+ ) } From 84e89df380954d9bd32d49325b3f40d4f749d526 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 20:55:48 +0000 Subject: [PATCH 09/37] Filter out built nextjs files for code coverage --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index e48b7b593a558..6e7c42ee5b76c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,5 +23,6 @@ module.exports = { "!/site/dev.ts", "!/site/next-env.d.ts", "!/site/next.config.js", + "!/site/out/**/*.*" ], } From 01e3f522b870a34c6c6990e65c289225d6620a49 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 20:59:40 +0000 Subject: [PATCH 10/37] Formatting --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 6e7c42ee5b76c..5be21ba836d8f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,6 +23,6 @@ module.exports = { "!/site/dev.ts", "!/site/next-env.d.ts", "!/site/next.config.js", - "!/site/out/**/*.*" + "!/site/out/**/*.*", ], } From ebffefed12725973bb90c27c3192fb80d3240bf7 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:24:59 +0000 Subject: [PATCH 11/37] Fix issue with 'variant' property --- site/components/Form/FormTextField.tsx | 52 +++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index ca39d502dcaba..5df20a133d39b 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -11,31 +11,31 @@ import { FormFieldProps } from "./types" */ export interface FormTextFieldProps extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. @@ -58,6 +58,8 @@ export interface FormTextFieldProps * without changing the actual underlying value. */ displayValueOverride?: string + + variant: "outlined" | "filled" | "standard" } /** From 2bca1ebfe9bcc9863ae80d20eb4bdedaebd305dd Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:26:38 +0000 Subject: [PATCH 12/37] Formatting --- site/components/Form/FormTextField.tsx | 50 +++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 5df20a133d39b..58c5b109afe2f 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -11,31 +11,31 @@ import { FormFieldProps } from "./types" */ export interface FormTextFieldProps extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. From 3098440d54b6b6397e7ebf101acd2ac075f0f3de Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:33:30 +0000 Subject: [PATCH 13/37] Add test for PasswordField --- site/components/Form/FormTextField.tsx | 3 ++- site/components/Form/PasswordField.test.tsx | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 site/components/Form/PasswordField.test.tsx diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 58c5b109afe2f..300efe4d043e2 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -59,7 +59,7 @@ export interface FormTextFieldProps */ displayValueOverride?: string - variant: "outlined" | "filled" | "standard" + variant?: "outlined" | "filled" | "standard" } /** @@ -102,6 +102,7 @@ export const formTextFieldFactory = (): React.FC> => { InputProps, onChange, type, + variant = "outlined", ...rest }) => { const isError = form.touched[formFieldName] && Boolean(form.errors[formFieldName]) diff --git a/site/components/Form/PasswordField.test.tsx b/site/components/Form/PasswordField.test.tsx new file mode 100644 index 0000000000000..09d766eae5e62 --- /dev/null +++ b/site/components/Form/PasswordField.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { PasswordField } from "./PasswordField" + +describe("PasswordField", () => { + it("renders", async () => { + // When + render() + + // Then + const element = await screen.findByText("Enter password") + expect(element).toBeDefined() + }) +}) From dde041ce9411abff8c47b948be9e0006c00d95be Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:40:28 +0000 Subject: [PATCH 14/37] Add FullScreenLoader + test --- .../Loader/FullScreenLoader.test.tsx | 15 +++++++++++++++ site/components/Loader/FullScreenLoader.tsx | 18 ++++++++++++++++++ .../components/Routing/AuthenticatedRouter.tsx | 3 ++- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 site/components/Loader/FullScreenLoader.test.tsx create mode 100644 site/components/Loader/FullScreenLoader.tsx diff --git a/site/components/Loader/FullScreenLoader.test.tsx b/site/components/Loader/FullScreenLoader.test.tsx new file mode 100644 index 0000000000000..82ab45ab8d40d --- /dev/null +++ b/site/components/Loader/FullScreenLoader.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { FullScreenLoader } from "./FullScreenLoader" + + +describe("FullScreenLoader", () => { + it("renders", async () => { + // When + render() + + // Then + const element = await screen.findByRole("progressbar") + expect(element).toBeDefined() + }) +}) diff --git a/site/components/Loader/FullScreenLoader.tsx b/site/components/Loader/FullScreenLoader.tsx new file mode 100644 index 0000000000000..749ba72b66a7c --- /dev/null +++ b/site/components/Loader/FullScreenLoader.tsx @@ -0,0 +1,18 @@ +import { CircularProgress, makeStyles } from "@material-ui/core" +import React from "react" + +export const useStyles = makeStyles(() => ({ + root: { + position: "absolute", + top: "0", + left: "0", + right: "0", + bottom: "0" + } +})) + +export const FullScreenLoader: React.FC = () => { + const styles = useStyles() + + return
+} \ No newline at end of file diff --git a/site/components/Routing/AuthenticatedRouter.tsx b/site/components/Routing/AuthenticatedRouter.tsx index 5e6440d2d3691..6ac712fc9db50 100644 --- a/site/components/Routing/AuthenticatedRouter.tsx +++ b/site/components/Routing/AuthenticatedRouter.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react" import { useRouter } from "next/router" +import { FullScreenLoader } from "../Loader/FullScreenLoader" import * as API from "./../../api" export const AuthenticatedRouter: React.FC = ({ children }) => { @@ -21,7 +22,7 @@ export const AuthenticatedRouter: React.FC = ({ children }) => { }, []) if (!isAuthenticated) { - return
loading
+ return } return <>{children} } From b9580c3d729b507dc8dfde16aa3043e9ae02ab7a Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:43:00 +0000 Subject: [PATCH 15/37] Add API to ignore list --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index 5be21ba836d8f..c602e23c7a6dd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ module.exports = { "/site/**/*.tsx", "!/site/**/*.stories.tsx", "!/site/.next/**/*.*", + "!/site/api.ts", "!/site/dev.ts", "!/site/next-env.d.ts", "!/site/next.config.js", From 8f52ae210b5e0330eb8ff1ba4f8085a0b19d7082 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:55:00 +0000 Subject: [PATCH 16/37] Add test case for success/failure logging in --- site/components/SignIn/SignInForm.tsx | 14 +++++-- site/components/SignIn/SignInFormTest.tsx | 47 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 site/components/SignIn/SignInFormTest.tsx diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index 6dadce1479ea0..349ba4f04992c 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -65,7 +65,15 @@ const useStyles = makeStyles((theme) => ({ }, })) -export const SignInForm: React.FC = () => { +export interface SignInProps { + loginHandler?: (email: string, password: string) => Promise + onLoginSuccess?: () => void +} + +export const SignInForm: React.FC = ({ + loginHandler = (email: string, password: string) => API.login(email, password), + onLoginSuccess = () => router.push("/") +}) => { const router = useRouter() const styles = useStyles() @@ -77,8 +85,8 @@ export const SignInForm: React.FC = () => { validationSchema, onSubmit: async ({ email, password }, helpers) => { try { - await API.login(email, password) - router.push("/") + await loginHandler(email, password) + onLoginSuccess() } catch (err) { helpers.setFieldError("password", "The username or password is incorrect.") } diff --git a/site/components/SignIn/SignInFormTest.tsx b/site/components/SignIn/SignInFormTest.tsx new file mode 100644 index 0000000000000..9cf35dc4bcbae --- /dev/null +++ b/site/components/SignIn/SignInFormTest.tsx @@ -0,0 +1,47 @@ +import React from "react" +import { render, screen } from "@testing-library/react" + +import { SignInForm } from "./SignInForm" + +describe("SignInForm", () => { + it("renders content", async () => { + // When + render() + + // Then + await screen.findByText("Sign In", { exact: false }) + }) + + it("shows an error message if SignIn fails", async () => { + // Given + const loginHandler = (_email: string, _password: string) => Promise.reject("Unacceptable credentials") + + // When + // Render the component + render() + // Click sign-in + const elem = await screen.findByRole("button") + elem.click() + + // Then + // Should see an error message + const errorMessage = await screen.findByText("The username or password is incorrect.") + expect(errorMessage).toBeDefined() + }) + + it("calls on login success when login completes", async () => { + // Given + const loginHandler = (_email: string, _password: string) => Promise.resolve() + const onLoginSuccess = jest.fn() + + // When + // Render the component + render() + // Click sign-in + const elem = await screen.findByRole("button") + elem.click() + + // Then + expect(onLoginSuccess).toHaveBeenCalledTimes(1) + }) +}) From 9c9456bb1597b25ad7c8da47f96bac5e6cc59bf9 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Thu, 20 Jan 2022 21:55:53 +0000 Subject: [PATCH 17/37] Formatting --- site/components/Loader/FullScreenLoader.test.tsx | 1 - site/components/Loader/FullScreenLoader.tsx | 12 ++++++++---- site/components/SignIn/SignInForm.tsx | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/site/components/Loader/FullScreenLoader.test.tsx b/site/components/Loader/FullScreenLoader.test.tsx index 82ab45ab8d40d..31a40dbfc4913 100644 --- a/site/components/Loader/FullScreenLoader.test.tsx +++ b/site/components/Loader/FullScreenLoader.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react" import React from "react" import { FullScreenLoader } from "./FullScreenLoader" - describe("FullScreenLoader", () => { it("renders", async () => { // When diff --git a/site/components/Loader/FullScreenLoader.tsx b/site/components/Loader/FullScreenLoader.tsx index 749ba72b66a7c..340bb8a7ddb09 100644 --- a/site/components/Loader/FullScreenLoader.tsx +++ b/site/components/Loader/FullScreenLoader.tsx @@ -7,12 +7,16 @@ export const useStyles = makeStyles(() => ({ top: "0", left: "0", right: "0", - bottom: "0" - } + bottom: "0", + }, })) export const FullScreenLoader: React.FC = () => { const styles = useStyles() - return
-} \ No newline at end of file + return ( +
+ +
+ ) +} diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index 349ba4f04992c..4269ddbda40e4 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -72,7 +72,7 @@ export interface SignInProps { export const SignInForm: React.FC = ({ loginHandler = (email: string, password: string) => API.login(email, password), - onLoginSuccess = () => router.push("/") + onLoginSuccess = () => router.push("/"), }) => { const router = useRouter() const styles = useStyles() From 48c8803bb37462b4f91d6a8c8390f98862ce8d28 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 21 Jan 2022 00:41:38 +0000 Subject: [PATCH 18/37] Fix SignIn test --- .../SignIn/{SignInFormTest.tsx => SignInForm.test.tsx} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename site/components/SignIn/{SignInFormTest.tsx => SignInForm.test.tsx} (92%) diff --git a/site/components/SignIn/SignInFormTest.tsx b/site/components/SignIn/SignInForm.test.tsx similarity index 92% rename from site/components/SignIn/SignInFormTest.tsx rename to site/components/SignIn/SignInForm.test.tsx index 9cf35dc4bcbae..254fb0c01cea7 100644 --- a/site/components/SignIn/SignInFormTest.tsx +++ b/site/components/SignIn/SignInForm.test.tsx @@ -20,7 +20,7 @@ describe("SignInForm", () => { // Render the component render() // Click sign-in - const elem = await screen.findByRole("button") + const elem = await screen.findByText("Sign In") elem.click() // Then @@ -38,7 +38,7 @@ describe("SignInForm", () => { // Render the component render() // Click sign-in - const elem = await screen.findByRole("button") + const elem = await screen.findByText("Sign In") elem.click() // Then From 763c0675c545bd3c3e806fd57c66e821ed5b561d Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 21 Jan 2022 00:41:50 +0000 Subject: [PATCH 19/37] Pull in FormFieldProps into FormTextField for now --- site/components/Form/FormTextField.tsx | 68 ++++++++++++++++---------- site/components/Form/types.ts | 16 ------ 2 files changed, 42 insertions(+), 42 deletions(-) delete mode 100644 site/components/Form/types.ts diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index 300efe4d043e2..537eab526c282 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -1,7 +1,22 @@ import TextField, { TextFieldProps } from "@material-ui/core/TextField" import React from "react" import { PasswordField } from "./PasswordField" -import { FormFieldProps } from "./types" +import { FormikContextType } from "formik" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikContextType + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} /** * FormTextFieldProps extends form-related MUI TextFieldProps with Formik @@ -11,31 +26,31 @@ import { FormFieldProps } from "./types" */ export interface FormTextFieldProps extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. @@ -117,6 +132,7 @@ export const formTextFieldFactory = (): React.FC> => { return ( { - /** - * form is a reference to a form or subform and is used to compute common - * states such as error and helper text - */ - form: FormikContextType - /** - * formFieldName is a field name associated with the form schema. - */ - formFieldName: keyof T -} From e32be0fcc95d188acc6dc1cb0bbb7285a48b20ee Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 21 Jan 2022 01:09:53 +0000 Subject: [PATCH 20/37] Fix spinner to be centered --- site/components/Loader/FullScreenLoader.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/components/Loader/FullScreenLoader.tsx b/site/components/Loader/FullScreenLoader.tsx index 340bb8a7ddb09..394f7c95ac16c 100644 --- a/site/components/Loader/FullScreenLoader.tsx +++ b/site/components/Loader/FullScreenLoader.tsx @@ -8,6 +8,9 @@ export const useStyles = makeStyles(() => ({ left: "0", right: "0", bottom: "0", + display: "flex", + justifyContent: "center", + alignItems: "center", }, })) From 4a0e4e5e3efec41301594851fc3979d8f0c7a3df Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 21 Jan 2022 01:18:24 +0000 Subject: [PATCH 21/37] Add LoadingButton --- site/components/Button/LoadingButton.tsx | 53 ++++++++++++++++++++++++ site/components/Button/index.ts | 3 +- site/components/SignIn/SignInForm.tsx | 8 ++-- 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 site/components/Button/LoadingButton.tsx diff --git a/site/components/Button/LoadingButton.tsx b/site/components/Button/LoadingButton.tsx new file mode 100644 index 0000000000000..c7609060d510c --- /dev/null +++ b/site/components/Button/LoadingButton.tsx @@ -0,0 +1,53 @@ + +import Button, { ButtonProps } from "@material-ui/core/Button" +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import * as React from "react" + +export interface LoadingButtonProps extends ButtonProps { + /** Whether or not to disable the button and show a spinner */ + loading?: boolean +} + +/** + * LoadingButton is a small wrapper around Material-UI's button to show a loading spinner + * + * In Material-UI 5+ - this is built-in, but since we're on an earlier version, + * we have to roll our own. + */ +export const LoadingButton: React.FC = ({ + loading = false, + children, + ...rest +}) => { + const styles = useStyles() + const hidden = loading ? { opacity: 0 } : undefined + + + return ( + + ) +} + +const useStyles = makeStyles( + (theme) => ({ + loader: { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + height: 18, + width: 18, + }, + spinner: { + color: theme.palette.text.disabled, + }, + }) +) diff --git a/site/components/Button/index.ts b/site/components/Button/index.ts index b5c759c0b3ed1..159d0f4e7c3f5 100644 --- a/site/components/Button/index.ts +++ b/site/components/Button/index.ts @@ -1 +1,2 @@ -export { SplitButton } from "./SplitButton" +export * from "./SplitButton" +export * from "./LoadingButton" \ No newline at end of file diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx index 4269ddbda40e4..28a5d3d1de664 100644 --- a/site/components/SignIn/SignInForm.tsx +++ b/site/components/SignIn/SignInForm.tsx @@ -7,7 +7,7 @@ import { formTextFieldFactory } from "../Form" import React from "react" import * as Yup from "yup" import { Welcome } from "./Welcome" -import Button from "@material-ui/core/Button" +import { LoadingButton } from "./../Button" /** * BuiltInAuthFormValues describes a form using built-in (email/password) @@ -127,16 +127,16 @@ export const SignInForm: React.FC = ({ />
- +
From 5ab009f9aba38c3301ae33f1796972d141417cc2 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Fri, 21 Jan 2022 01:18:34 +0000 Subject: [PATCH 22/37] Formatting --- site/components/Button/LoadingButton.tsx | 38 +++++++----------- site/components/Button/index.ts | 2 +- site/components/Form/FormTextField.tsx | 50 ++++++++++++------------ 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/site/components/Button/LoadingButton.tsx b/site/components/Button/LoadingButton.tsx index c7609060d510c..f03b694095d4a 100644 --- a/site/components/Button/LoadingButton.tsx +++ b/site/components/Button/LoadingButton.tsx @@ -1,4 +1,3 @@ - import Button, { ButtonProps } from "@material-ui/core/Button" import CircularProgress from "@material-ui/core/CircularProgress" import { makeStyles } from "@material-ui/core/styles" @@ -11,19 +10,14 @@ export interface LoadingButtonProps extends ButtonProps { /** * LoadingButton is a small wrapper around Material-UI's button to show a loading spinner - * + * * In Material-UI 5+ - this is built-in, but since we're on an earlier version, * we have to roll our own. */ -export const LoadingButton: React.FC = ({ - loading = false, - children, - ...rest -}) => { +export const LoadingButton: React.FC = ({ loading = false, children, ...rest }) => { const styles = useStyles() const hidden = loading ? { opacity: 0 } : undefined - return (