From 42d3bb8508c666c28250432ada734d58ccb0eca8 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 6 Dec 2019 18:09:01 +0100 Subject: [PATCH] feat: login Dialog Component - Replaced class by func. comp + added react-hook-form (#341) * refactor: convert class to func * refactor: changed login form logic * refactor: conver to testing-library tests * refactor: moved dependency * refactor: replaced uglifyjs-webpack-plugin by terser-webpack-plugin * fix: fixed e2e errors * fix: fixed e2e test * Delete settings.json * fix: vscode settings rollback * refactor: rollback webpack config * refactor: updated eslint rule * fix: removed --fix * refactor: incresed the bundle size --- .eslintrc | 4 +- .vscode/settings.json | 2 +- jest/setup.ts | 1 + package.json | 7 +- src/App/App.tsx | 1 - src/App/AppContext.ts | 4 - src/App/AppContextProvider.tsx | 10 - src/App/AppRoute.tsx | 1 - src/components/ActionBar/ActionBarAction.tsx | 1 - src/components/Dist/Dist.tsx | 3 - src/components/Engines/Engines.tsx | 2 - src/components/Header/Header.test.tsx | 7 +- src/components/Header/Header.tsx | 33 +-- src/components/Header/HeaderMenu.tsx | 1 - src/components/Login/Login.test.tsx | 127 --------- src/components/Login/Login.tsx | 254 ------------------ .../Login/__snapshots__/Login.test.tsx.snap | 5 - src/components/Login/index.ts | 1 - src/components/Login/styles.ts | 23 -- .../LoginDialog/LoginDialog.test.tsx | 106 ++++++++ src/components/LoginDialog/LoginDialog.tsx | 56 ++++ .../LoginDialog/LoginDialogCloseButton.tsx | 28 ++ .../LoginDialog/LoginDialogForm.tsx | 94 +++++++ .../LoginDialog/LoginDialogFormError.tsx | 42 +++ .../LoginDialog/LoginDialogHeader.tsx | 44 +++ .../__snapshots__/LoginDialog.test.tsx.snap | 3 + src/components/LoginDialog/index.ts | 1 + src/components/Package/Package.tsx | 4 - src/components/Repository/Repository.tsx | 3 - src/pages/Version/Version.test.tsx | 1 - src/utils/login.test.ts | 5 +- src/utils/login.ts | 6 +- test/e2e/e2e.spec.js | 11 +- yarn.lock | 22 +- 34 files changed, 416 insertions(+), 497 deletions(-) delete mode 100644 src/components/Login/Login.test.tsx delete mode 100644 src/components/Login/Login.tsx delete mode 100644 src/components/Login/__snapshots__/Login.test.tsx.snap delete mode 100644 src/components/Login/index.ts delete mode 100644 src/components/Login/styles.ts create mode 100644 src/components/LoginDialog/LoginDialog.test.tsx create mode 100644 src/components/LoginDialog/LoginDialog.tsx create mode 100644 src/components/LoginDialog/LoginDialogCloseButton.tsx create mode 100644 src/components/LoginDialog/LoginDialogForm.tsx create mode 100644 src/components/LoginDialog/LoginDialogFormError.tsx create mode 100644 src/components/LoginDialog/LoginDialogHeader.tsx create mode 100644 src/components/LoginDialog/__snapshots__/LoginDialog.test.tsx.snap create mode 100644 src/components/LoginDialog/index.ts diff --git a/.eslintrc b/.eslintrc index 7d4da4a..5972fb3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -72,7 +72,7 @@ "arrow": "parens", "condition": "parens", "logical": "parens", - "prop": "parens" + "prop": "ignore" }], "react/jsx-boolean-value": ["error", "always"], "react/jsx-closing-tag-location": ["error"], @@ -83,7 +83,7 @@ "react/jsx-indent": ["error", 2], "react/jsx-indent-props": ["error", 2], "react/jsx-key": ["error"], - "react/jsx-max-depth": ["error", { "max": 2}], + "react/jsx-max-depth":["error", { "max": 5}], "react/jsx-max-props-per-line": ["error", {"maximum": 3, "when": "multiline" }], "react/jsx-no-bind": ["error"], "react/jsx-no-comment-textnodes": ["error"], diff --git a/.vscode/settings.json b/.vscode/settings.json index 76537af..e24418c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,4 +7,4 @@ "typescriptreact" ], "typescript.tsdk": "node_modules/typescript/lib" -} \ No newline at end of file +} \ No newline at end of file diff --git a/jest/setup.ts b/jest/setup.ts index 293e0ef..ce94df9 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -6,6 +6,7 @@ import 'raf/polyfill'; import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import { GlobalWithFetchMock } from 'jest-fetch-mock'; +import 'mutationobserver-shim'; // @ts-ignore : Only a void function can be called with the 'new' keyword configure({ adapter: new Adapter() }); diff --git a/package.json b/package.json index 9ea5556..2f8469a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "lockfile-lint": "3.0.3", "lodash": "^4.17.15", "mini-css-extract-plugin": "0.8.0", + "mutationobserver-shim": "0.3.3", "node-mocks-http": "1.8.0", "normalize.css": "8.0.1", "optimize-css-assets-webpack-plugin": "5.0.3", @@ -96,6 +97,7 @@ "react": "16.12.0", "react-autosuggest": "9.4.3", "react-dom": "16.12.0", + "react-hook-form": "3.28.12", "react-hot-loader": "4.12.18", "react-router-dom": "5.1.2", "request": "2.88.0", @@ -136,7 +138,7 @@ "bundlesize": [ { "path": "./static/vendors.*.js", - "maxSize": "180 kB" + "maxSize": "185 kB" }, { "path": "./static/main.*.js", @@ -213,6 +215,5 @@ "type": "opencollective", "url": "https://opencollective.com/verdaccio", "logo": "https://opencollective.com/verdaccio/logo.txt" - }, - "dependencies": {} + } } diff --git a/src/App/App.tsx b/src/App/App.tsx index 8eff4b5..f645847 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -26,7 +26,6 @@ const StyledBoxContent = styled(Box)({ }, }); -/* eslint-disable react/jsx-max-depth */ /* eslint-disable react/jsx-no-bind */ /* eslint-disable react-hooks/exhaustive-deps */ const App: React.FC = () => { diff --git a/src/App/AppContext.ts b/src/App/AppContext.ts index e41a73c..70da778 100644 --- a/src/App/AppContext.ts +++ b/src/App/AppContext.ts @@ -1,9 +1,6 @@ import { createContext } from 'react'; -import { FormError } from '../components/Login/Login'; - export interface AppProps { - error?: FormError; user?: User; scope: string; packages: any[]; @@ -15,7 +12,6 @@ export interface User { export interface AppContextProps extends AppProps { setUser: (user?: User) => void; - setError: (error?: FormError) => void; } const AppContext = createContext(undefined); diff --git a/src/App/AppContextProvider.tsx b/src/App/AppContextProvider.tsx index 2cf5351..92805fe 100644 --- a/src/App/AppContextProvider.tsx +++ b/src/App/AppContextProvider.tsx @@ -1,7 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { FormError } from '../components/Login/Login'; - import AppContext, { AppProps, User } from './AppContext'; interface Props { @@ -38,19 +36,11 @@ const AppContextProvider: React.FC = ({ children, packages, user }) => { }); }; - const setError = (error?: FormError) => { - setState({ - ...state, - error, - }); - }; - return ( {children} diff --git a/src/App/AppRoute.tsx b/src/App/AppRoute.tsx index 2b1d737..4f64a13 100644 --- a/src/App/AppRoute.tsx +++ b/src/App/AppRoute.tsx @@ -23,7 +23,6 @@ export const history = createBrowserHistory({ basename: window.__VERDACCIO_BASENAME_UI_OPTIONS && window.__VERDACCIO_BASENAME_UI_OPTIONS.url_prefix, }); -/* eslint react/jsx-max-depth: 0 */ const AppRoute: React.FC = () => { const appContext = useContext(AppContext); diff --git a/src/components/ActionBar/ActionBarAction.tsx b/src/components/ActionBar/ActionBarAction.tsx index 9fb6fec..1192f16 100644 --- a/src/components/ActionBar/ActionBarAction.tsx +++ b/src/components/ActionBar/ActionBarAction.tsx @@ -25,7 +25,6 @@ export interface ActionBarActionProps { } /* eslint-disable react/jsx-no-bind */ -/* eslint-disable react/jsx-max-depth */ const ActionBarAction: React.FC = ({ type, link }) => { switch (type) { case 'VISIT_HOMEPAGE': diff --git a/src/components/Dist/Dist.tsx b/src/components/Dist/Dist.tsx index c5369a1..4405779 100644 --- a/src/components/Dist/Dist.tsx +++ b/src/components/Dist/Dist.tsx @@ -10,8 +10,6 @@ import { StyledText, DistListItem, DistChips } from './styles'; const DistChip: FC<{ name: string }> = ({ name, children }) => children ? ( {name} @@ -19,7 +17,6 @@ const DistChip: FC<{ name: string }> = ({ name, children }) => {children} } - /* eslint-enable */ /> ) : null; diff --git a/src/components/Engines/Engines.tsx b/src/components/Engines/Engines.tsx index 3eba5ef..5e8ea9a 100644 --- a/src/components/Engines/Engines.tsx +++ b/src/components/Engines/Engines.tsx @@ -19,7 +19,6 @@ const Engine: React.FC = () => { return null; } - /* eslint-disable react/jsx-max-depth */ return ( {engines.node && ( @@ -45,7 +44,6 @@ const Engine: React.FC = () => { )} ); - /* eslint-enable react/jsx-max-depth */ }; export default Engine; diff --git a/src/components/Header/Header.test.tsx b/src/components/Header/Header.test.tsx index dee9036..fdfef85 100644 --- a/src/components/Header/Header.test.tsx +++ b/src/components/Header/Header.test.tsx @@ -44,7 +44,7 @@ describe('
component with logged in state', () => { }); test('should open login dialog', async () => { - const { getByText, getByTestId } = render( + const { getByText } = render(
@@ -54,9 +54,8 @@ describe('
component with logged in state', () => { const loginBtn = getByText('Login'); fireEvent.click(loginBtn); - // wait for login modal appearance and return the element - const registrationInfoModal = await waitForElement(() => getByTestId('login--form-container')); - expect(registrationInfoModal).toBeTruthy(); + const loginDialog = await waitForElement(() => getByText('Sign in')); + expect(loginDialog).toBeTruthy(); }); test('should logout the user', async () => { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index c72a2ef..4d68cd1 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -2,10 +2,9 @@ import React, { useState, useContext } from 'react'; import storage from '../../utils/storage'; import { getRegistryURL } from '../../utils/url'; -import { makeLogin } from '../../utils/login'; import Button from '../../muiComponents/Button'; import AppContext from '../../App/AppContext'; -import LoginModal from '../Login'; +import LoginDialog from '../LoginDialog'; import Search from '../Search'; import { NavBar, InnerNavBar, MobileNavBar, InnerMobileNavBar } from './styles'; @@ -17,7 +16,6 @@ interface Props { withoutSearch?: boolean; } -/* eslint-disable react/jsx-max-depth */ /* eslint-disable react/jsx-no-bind*/ const Header: React.FC = ({ withoutSearch }) => { const appContext = useContext(AppContext); @@ -29,29 +27,9 @@ const Header: React.FC = ({ withoutSearch }) => { throw Error('The app Context was not correct used'); } - const { user, scope, error, setUser, setError } = appContext; + const { user, scope, setUser } = appContext; const logo = window.VERDACCIO_LOGO; - /** - * handles login - * Required by:
- */ - const handleDoLogin = async (usernameValue: string, passwordValue: string) => { - const { username, token, error } = await makeLogin(usernameValue, passwordValue); - - if (username && token) { - storage.setItem('username', username); - storage.setItem('token', token); - setUser({ username }); - setShowLoginModal(false); - } - - if (error) { - setUser(undefined); - setError(error); - } - }; - /** * Logouts user * Required by:
@@ -93,12 +71,7 @@ const Header: React.FC = ({ withoutSearch }) => { )} - setShowLoginModal(false)} - onSubmit={handleDoLogin} - visibility={showLoginModal} - /> + {!user && setShowLoginModal(false)} open={showLoginModal} />} ); }; diff --git a/src/components/Header/HeaderMenu.tsx b/src/components/Header/HeaderMenu.tsx index 295beb7..d5d6ae1 100644 --- a/src/components/Header/HeaderMenu.tsx +++ b/src/components/Header/HeaderMenu.tsx @@ -16,7 +16,6 @@ interface Props { onLoggedInMenuClose: () => void; } -/* eslint-disable react/jsx-max-depth */ const HeaderMenu: React.FC = ({ onLogout, username, diff --git a/src/components/Login/Login.test.tsx b/src/components/Login/Login.test.tsx deleted file mode 100644 index 9cda75d..0000000 --- a/src/components/Login/Login.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @prettier - */ - -import React from 'react'; - -import { mount } from '../../utils/test-enzyme'; - -import LoginModal from './Login'; - -const eventUsername = { - target: { - value: 'xyz', - }, -}; - -const eventPassword = { - target: { - value: '1234', - }, -}; - -const event = { - preventDefault: jest.fn(), -}; - -describe('', () => { - test('should load the component in default state', () => { - const wrapper = mount(); - expect(wrapper.html()).toMatchSnapshot(); - }); - - test('should load the component with props', () => { - const props = { - visibility: true, - error: { - type: 'error', - title: 'Error Title', - description: 'Error Description', - }, - onCancel: () => {}, - onSubmit: () => {}, - }; - const wrapper = mount(); - expect(wrapper.html()).toMatchSnapshot(); - }); - - test('onCancel: should close the login modal', () => { - const props = { - visibility: true, - error: { - type: 'error', - title: 'Error Title', - description: 'Error Description', - }, - onCancel: jest.fn(), - onSubmit: () => {}, - }; - const wrapper = mount(); - wrapper.find('button[id="login--form-cancel"]').simulate('click'); - expect(props.onCancel).toHaveBeenCalled(); - }); - - test('setCredentials - should set username and password in state', () => { - const props = { - visibility: true, - onCancel: () => {}, - onSubmit: () => {}, - }; - const wrapper = mount(); - const { setCredentials } = wrapper.instance(); - - expect(setCredentials('username', eventUsername)).toBeUndefined(); - expect(wrapper.state('form').username.value).toEqual('xyz'); - - expect(setCredentials('password', eventPassword)).toBeUndefined(); - expect(wrapper.state('form').password.value).toEqual('1234'); - }); - - test('validateCredentials: should validate credentials', async () => { - const props = { - visibility: true, - onCancel: () => {}, - onSubmit: jest.fn(), - }; - - const wrapper = mount(); - const instance = wrapper.instance(); - - instance.submitCredentials = jest.fn(); - const { handleValidateCredentials, setCredentials, submitCredentials } = instance; - - expect(setCredentials('username', eventUsername)).toBeUndefined(); - expect(wrapper.state('form').username.value).toEqual('xyz'); - - expect(setCredentials('password', eventPassword)).toBeUndefined(); - expect(wrapper.state('form').password.value).toEqual('1234'); - - handleValidateCredentials(event); - expect(event.preventDefault).toHaveBeenCalled(); - expect(wrapper.state('form').username.pristine).toEqual(false); - expect(wrapper.state('form').password.pristine).toEqual(false); - - expect(submitCredentials).toHaveBeenCalledTimes(1); - }); - - test('submitCredentials: should submit credentials', async () => { - const props = { - onSubmit: jest.fn(), - }; - - const wrapper = mount(); - const { setCredentials, submitCredentials } = wrapper.instance(); - expect(setCredentials('username', eventUsername)).toBeUndefined(); - expect(wrapper.state('form').username.value).toEqual('xyz'); - - expect(setCredentials('password', eventPassword)).toBeUndefined(); - expect(wrapper.state('form').password.value).toEqual('1234'); - - await submitCredentials(); - expect(props.onSubmit).toHaveBeenCalledWith('xyz', '1234'); - expect(wrapper.state('form').username.value).toEqual(''); - expect(wrapper.state('form').username.pristine).toEqual(true); - expect(wrapper.state('form').password.value).toEqual(''); - expect(wrapper.state('form').password.pristine).toEqual(true); - }); -}); diff --git a/src/components/Login/Login.tsx b/src/components/Login/Login.tsx deleted file mode 100644 index d104480..0000000 --- a/src/components/Login/Login.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React, { Component } from 'react'; -import ErrorIcon from '@material-ui/icons/Error'; - -import Button from '../../muiComponents/Button'; -import Dialog from '../../muiComponents/Dialog'; -import DialogTitle from '../../muiComponents/DialogTitle'; -import DialogContent from '../../muiComponents/DialogContent'; -import DialogActions from '../../muiComponents/DialogActions'; -import FormControl from '../../muiComponents/FormControl'; -import FormHelperText from '../../muiComponents/FormHelperText'; -import Input from '../../muiComponents/Input'; -import InputLabel from '../../muiComponents/InputLabel'; -import SnackbarContent from '../../muiComponents/SnackbarContent'; - -import * as classes from './styles'; - -interface FormFields { - required: boolean; - pristine: boolean; - helperText: string; - value: string; -} -export interface FormError { - type: string; - title: string; - description: string; -} - -interface LoginModalProps { - visibility: boolean; - error?: FormError; - onCancel: () => void; - onSubmit: (username: string, password: string) => void; -} - -interface LoginModalState { - form: { - username: Partial; - password: Partial; - }; - error?: FormError; -} - -export default class LoginModal extends Component, LoginModalState> { - constructor(props: LoginModalProps) { - super(props); - this.state = { - form: { - username: { - required: true, - pristine: true, - helperText: 'Field required', - value: '', - }, - password: { - required: true, - pristine: true, - helperText: 'Field required', - value: '', - }, - }, - error: props.error, - }; - } - - public render(): JSX.Element { - const { visibility = true, onCancel = () => null, error } = this.props as LoginModalProps; - return ( - -
- {'Login'} - - {error && this.renderLoginError(error)} - {this.renderNameField()} - {this.renderPasswordField()} - - {this.renderActions()} -
-
- ); - } - - /** - * set login modal's username and password to current state - * Required to login - */ - public setCredentials = (name, e) => { - const { form } = this.state; - this.setState({ - form: { - ...form, - [name]: { - ...form[name], - value: e.target.value, - pristine: false, - }, - }, - }); - }; - - public handleUsernameChange = event => { - this.setCredentials('username', event); - }; - - public handlePasswordChange = event => { - this.setCredentials('password', event); - }; - - public handleValidateCredentials = event => { - const { form } = this.state; - // prevents default submit behavior - event.preventDefault(); - - this.setState( - { - form: Object.keys(form).reduce( - (acc, key) => ({ - ...acc, - ...{ [key]: { ...form[key], pristine: false } }, - }), - { username: {}, password: {} } - ), - }, - () => { - if (!Object.keys(form).some(id => !form[id])) { - this.submitCredentials(); - } - } - ); - }; - - public submitCredentials = async () => { - const { form } = this.state; - const username = (form.username && form.username.value) || ''; - const password = (form.password && form.password.value) || ''; - const { onSubmit } = this.props; - if (onSubmit) { - await onSubmit(username, password); - } - // let's wait for API response and then set - // username and password filed to empty state - this.setState({ - form: Object.keys(form).reduce( - (acc, key) => ({ - ...acc, - ...{ [key]: { ...form[key], value: '', pristine: true } }, - }), - { username: {}, password: {} } - ), - }); - }; - - public renderErrorMessage(title: string, description: string): JSX.Element { - return ( - -
- {title} -
-
{description}
-
- ); - } - - public renderMessage(title: string, description: string): JSX.Element { - return ( -
- - {this.renderErrorMessage(title, description)} -
- ); - } - - public renderLoginError({ type, title, description }: FormError): JSX.Element | false { - return ( - type === 'error' && ( - - ) - ); - } - - public renderNameField = () => { - const { - form: { username }, - } = this.state; - return ( - - {'Username'} - - {!username.value && !username.pristine && ( - {username.helperText} - )} - - ); - }; - - public renderPasswordField = () => { - const { - form: { password }, - } = this.state; - return ( - - {'Password'} - - {!password.value && !password.pristine && ( - {password.helperText} - )} - - ); - }; - - public renderActions = () => { - const { - form: { username, password }, - } = this.state; - const { onCancel } = this.props; - return ( - - - - - ); - }; -} diff --git a/src/components/Login/__snapshots__/Login.test.tsx.snap b/src/components/Login/__snapshots__/Login.test.tsx.snap deleted file mode 100644 index df29d7e..0000000 --- a/src/components/Login/__snapshots__/Login.test.tsx.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` should load the component in default state 1`] = `"

Login

"`; - -exports[` should load the component with props 1`] = `"

Login

Error Title
Error Description
"`; diff --git a/src/components/Login/index.ts b/src/components/Login/index.ts deleted file mode 100644 index 2a741cd..0000000 --- a/src/components/Login/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Login'; diff --git a/src/components/Login/styles.ts b/src/components/Login/styles.ts deleted file mode 100644 index 72996de..0000000 --- a/src/components/Login/styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { css } from 'emotion'; - -import { theme } from '../../design-tokens/theme'; - -export const loginDialog = css({ - minWidth: '300px', -}); - -export const loginError = css({ - backgroundColor: `${theme.palette.red} !important`, - minWidth: 'inherit !important', - marginBottom: '10px !important', -}); - -export const loginErrorMsg = css({ - display: 'flex', - alignItems: 'center', -}); - -export const loginIcon = css({ - opacity: 0.9, - marginRight: '8px', -}); diff --git a/src/components/LoginDialog/LoginDialog.test.tsx b/src/components/LoginDialog/LoginDialog.test.tsx new file mode 100644 index 0000000..ab104ba --- /dev/null +++ b/src/components/LoginDialog/LoginDialog.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { render, waitForElement, fireEvent, waitForElementToBeRemoved } from '../../utils/test-react-testing-library'; +import AppContext, { AppContextProps } from '../../App/AppContext'; +import api from '../../utils/api'; + +import LoginDialog from './LoginDialog'; + +const appContextValue: AppContextProps = { + scope: '', + packages: [], + setUser: jest.fn(), +}; + +describe(' component', () => { + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + }); + + test('should render the component in default state', () => { + const props = { + onClose: jest.fn(), + }; + const { container } = render( + + + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should load the component with the open prop', async () => { + const props = { + open: true, + onClose: jest.fn(), + }; + + const { getByText } = render( + + + + ); + + const loginDialogHeading = await waitForElement(() => getByText('Sign in')); + expect(loginDialogHeading).toBeTruthy(); + }); + + test('onClose: should close the login modal', async () => { + const props = { + open: true, + onClose: jest.fn(), + }; + + const { getByTestId } = render( + + + + ); + + const loginDialogButton = await waitForElement(() => getByTestId('close-login-dialog-button')); + expect(loginDialogButton).toBeTruthy(); + + fireEvent.click(loginDialogButton, { open: false }); + expect(props.onClose).toHaveBeenCalled(); + }); + + // TODO + test.skip('setCredentials - should set username and password in state', async () => { + const props = { + open: true, + onClose: jest.fn(), + }; + + jest.spyOn(api, 'request').mockImplementation(() => + Promise.resolve({ + username: 'xyz', + token: 'djsadaskljd', + }) + ); + + const { getByPlaceholderText, getByText } = render( + + + + ); + + // TODO: the input's value is not being updated in the DOM + const userNameInput = getByPlaceholderText('Your username'); + fireEvent.focus(userNameInput); + fireEvent.change(userNameInput, { target: { value: 'xyz' } }); + + // TODO: the input's value is not being updated in the DOM + const passwordInput = getByPlaceholderText('Your strong password'); + fireEvent.focus(passwordInput); + fireEvent.change(passwordInput, { target: { value: '1234' } }); + + // TODO: submitting form does not work + const signInButton = getByText('Sign in'); + fireEvent.click(signInButton); + }); + + test.todo('validateCredentials: should validate credentials'); + + test.todo('submitCredentials: should submit credentials'); +}); diff --git a/src/components/LoginDialog/LoginDialog.tsx b/src/components/LoginDialog/LoginDialog.tsx new file mode 100644 index 0000000..c405397 --- /dev/null +++ b/src/components/LoginDialog/LoginDialog.tsx @@ -0,0 +1,56 @@ +import React, { useState, useContext, useCallback } from 'react'; + +import { makeLogin } from '../../utils/login'; +import storage from '../../utils/storage'; +import Dialog from '../../muiComponents/Dialog'; +import DialogContent from '../../muiComponents/DialogContent'; +import AppContext from '../../App/AppContext'; + +import LoginDialogCloseButton from './LoginDialogCloseButton'; +import LoginDialogForm, { FormValues } from './LoginDialogForm'; +import LoginDialogHeader from './LoginDialogHeader'; + +interface Props { + open?: boolean; + onClose: () => void; +} + +const LoginDialog: React.FC = ({ onClose, open = false }) => { + const appContext = useContext(AppContext); + + if (!appContext) { + throw Error('The app Context was not correct used'); + } + + const [error, setError] = useState(); + + const handleDoLogin = useCallback( + async (data: FormValues) => { + const { username, token, error } = await makeLogin(data.username, data.password); + + if (error) { + setError(error); + } + + if (username && token) { + storage.setItem('username', username); + storage.setItem('token', token); + appContext.setUser({ username }); + onClose(); + } + }, + [appContext, onClose] + ); + + return ( + + + + + + + + ); +}; + +export default LoginDialog; diff --git a/src/components/LoginDialog/LoginDialogCloseButton.tsx b/src/components/LoginDialog/LoginDialogCloseButton.tsx new file mode 100644 index 0000000..45c0222 --- /dev/null +++ b/src/components/LoginDialog/LoginDialogCloseButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import CloseIcon from '@material-ui/icons/Close'; + +import DialogTitle from '../../muiComponents/DialogTitle'; +import IconButton from '../../muiComponents/IconButton'; +import { Theme } from '../../design-tokens/theme'; + +const StyledIconButton = styled(IconButton)<{ theme?: Theme }>(({ theme }) => ({ + position: 'absolute', + right: theme.spacing() / 2, + top: theme.spacing() / 2, + color: theme.palette.grey[500], +})); + +interface Props { + onClose: () => void; +} + +const LoginDialogCloseButton: React.FC = ({ onClose }) => ( + + + + + +); + +export default LoginDialogCloseButton; diff --git a/src/components/LoginDialog/LoginDialogForm.tsx b/src/components/LoginDialog/LoginDialogForm.tsx new file mode 100644 index 0000000..8f7bc52 --- /dev/null +++ b/src/components/LoginDialog/LoginDialogForm.tsx @@ -0,0 +1,94 @@ +import React, { memo } from 'react'; +import styled from '@emotion/styled'; +import useForm from 'react-hook-form/dist/react-hook-form.ie11'; + +import TextField from '../../muiComponents/TextField'; +import Button from '../../muiComponents/Button'; +import { Theme } from '../../design-tokens/theme'; +import { LoginError } from '../../utils/login'; + +import LoginDialogFormError from './LoginDialogFormError'; + +const StyledForm = styled('form')<{ theme?: Theme }>(({ theme }) => ({ + marginTop: theme.spacing(1), +})); + +const StyledButton = styled(Button)<{ theme?: Theme }>(({ theme }) => ({ + margin: theme.spacing(3, 0, 2), +})); + +export interface FormValues { + username: string; + password: string; +} + +interface Props { + onSubmit: (formValues: FormValues) => void; + error?: LoginError; +} + +const LoginDialogForm = memo(({ onSubmit, error }: Props) => { + const { + register, + errors, + handleSubmit, + formState: { isValid }, + } = useForm({ mode: 'onChange' }); + + const onSubmitForm = (formValues: FormValues) => { + onSubmit(formValues); + }; + + return ( + + + + {error && } + + {'Sign In'} + + + ); +}); + +export default LoginDialogForm; diff --git a/src/components/LoginDialog/LoginDialogFormError.tsx b/src/components/LoginDialog/LoginDialogFormError.tsx new file mode 100644 index 0000000..16f26e9 --- /dev/null +++ b/src/components/LoginDialog/LoginDialogFormError.tsx @@ -0,0 +1,42 @@ +import React, { memo } from 'react'; +import styled from '@emotion/styled'; +import Error from '@material-ui/icons/Error'; + +import SnackbarContent from '../../muiComponents/SnackbarContent'; +import Box from '../../muiComponents/Box'; +import { Theme } from '../../design-tokens/theme'; +import { LoginError } from '../../utils/login'; + +const StyledSnackbarContent = styled(SnackbarContent)<{ theme?: Theme }>(({ theme }) => ({ + backgroundColor: theme.palette.error.dark, +})); + +const StyledErrorIcon = styled(Error)<{ theme?: Theme }>(({ theme }) => ({ + fontSize: 20, + opacity: 0.9, + marginRight: theme.spacing(1), +})); + +export interface FormValues { + username: string; + password: string; +} + +interface Props { + error: LoginError; +} + +const LoginDialogFormError = memo(({ error }: Props) => { + return ( + + + {error.description} + + } + /> + ); +}); + +export default LoginDialogFormError; diff --git a/src/components/LoginDialog/LoginDialogHeader.tsx b/src/components/LoginDialog/LoginDialogHeader.tsx new file mode 100644 index 0000000..62cee3c --- /dev/null +++ b/src/components/LoginDialog/LoginDialogHeader.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import LockOutlined from '@material-ui/icons/LockOutlined'; +import CloseIcon from '@material-ui/icons/Close'; + +import Heading from '../../muiComponents/Heading'; +import Avatar from '../../muiComponents/Avatar'; +import Box from '../../muiComponents/Box'; +import IconButton from '../../muiComponents/IconButton'; +import { Theme } from '../../design-tokens/theme'; + +const StyledAvatar = styled(Avatar)<{ theme?: Theme }>(({ theme }) => ({ + margin: theme.spacing(1), + backgroundColor: theme.palette.primary.main, +})); + +const StyledIconButton = styled(IconButton)<{ theme?: Theme }>(({ theme }) => ({ + position: 'absolute', + right: theme.spacing() / 2, + top: theme.spacing() / 2, + color: theme.palette.grey[500], +})); + +interface Props { + onClose?: () => void; +} + +const LoginDialogHeader: React.FC = ({ onClose }) => { + return ( + + {onClose && ( + + + + )} + + + + {'Sign in'} + + ); +}; + +export default LoginDialogHeader; diff --git a/src/components/LoginDialog/__snapshots__/LoginDialog.test.tsx.snap b/src/components/LoginDialog/__snapshots__/LoginDialog.test.tsx.snap new file mode 100644 index 0000000..653bc96 --- /dev/null +++ b/src/components/LoginDialog/__snapshots__/LoginDialog.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` component should render the component in default state 1`] = `null`; diff --git a/src/components/LoginDialog/index.ts b/src/components/LoginDialog/index.ts new file mode 100644 index 0000000..e46b684 --- /dev/null +++ b/src/components/LoginDialog/index.ts @@ -0,0 +1 @@ +export { default } from './LoginDialog'; diff --git a/src/components/Package/Package.tsx b/src/components/Package/Package.tsx index cac856f..adae463 100644 --- a/src/components/Package/Package.tsx +++ b/src/components/Package/Package.tsx @@ -114,7 +114,6 @@ const Package: React.FC = ({ - {/* eslint-disable-next-line react/jsx-max-depth */} @@ -128,7 +127,6 @@ const Package: React.FC = ({ - {/* eslint-disable-next-line react/jsx-max-depth */} @@ -143,7 +141,6 @@ const Package: React.FC = ({ - {/* eslint-disable-next-line react/jsx-max-depth */} @@ -155,7 +152,6 @@ const Package: React.FC = ({ - {/* eslint-disable-next-line react/jsx-max-depth */} {packageName} diff --git a/src/components/Repository/Repository.tsx b/src/components/Repository/Repository.tsx index d267a1a..a94384c 100644 --- a/src/components/Repository/Repository.tsx +++ b/src/components/Repository/Repository.tsx @@ -1,5 +1,3 @@ -/* eslint react/jsx-max-depth: 0 */ - import React from 'react'; import styled from '@emotion/styled'; @@ -37,7 +35,6 @@ const RepositoryListItemText = styled(ListItemText)({ margin: 0, }); -/* eslint-disable react/jsx-wrap-multilines */ const Repository: React.FC = () => { const detailContext = React.useContext(DetailContext); diff --git a/src/pages/Version/Version.test.tsx b/src/pages/Version/Version.test.tsx index 0fc5dc9..0ef3904 100644 --- a/src/pages/Version/Version.test.tsx +++ b/src/pages/Version/Version.test.tsx @@ -23,7 +23,6 @@ const detailContextValue = { }; describe('test Version page', () => { - /* eslint-disable react/jsx-max-depth */ test('should render the version page', async () => { const { getByTestId, getByText } = render( diff --git a/src/utils/login.test.ts b/src/utils/login.test.ts index 68ad63a..13f885e 100644 --- a/src/utils/login.test.ts +++ b/src/utils/login.test.ts @@ -59,7 +59,6 @@ describe('makeLogin', (): void => { const result = { error: { description: "Username or password can't be empty!", - title: 'Unable to login', type: 'error', }, }; @@ -77,8 +76,7 @@ describe('makeLogin', (): void => { test('makeLogin - login should failed with 401', async () => { const result = { error: { - description: 'bad username/password, access denied', - title: 'Unable to login', + description: 'Unable to sign in', type: 'error', }, }; @@ -91,7 +89,6 @@ describe('makeLogin', (): void => { test('makeLogin - login should failed with when no data is sent', async () => { const result = { error: { - title: 'Unable to login', type: 'error', description: "Username or password can't be empty!", }, diff --git a/src/utils/login.ts b/src/utils/login.ts index d06ae01..5da41a9 100644 --- a/src/utils/login.ts +++ b/src/utils/login.ts @@ -47,7 +47,6 @@ export interface LoginBody { } export interface LoginError { - title: string; type: string; description: string; } @@ -56,7 +55,6 @@ export async function makeLogin(username?: string, password?: string): Promise { await clickElement('button[data-testid="header--button-login"]'); await page.waitFor(500); // we fill the sign in form - const signInDialog = await page.$('#login--form-container'); - const userInput = await signInDialog.$('#login--form-username'); + const signInDialog = await page.$('#login--dialog'); + const userInput = await signInDialog.$('#login--dialog-username'); expect(userInput).not.toBeNull(); - const passInput = await signInDialog.$('#login--form-password'); + const passInput = await signInDialog.$('#login--dialog-password'); expect(passInput).not.toBeNull(); await userInput.type('test', { delay: 100 }); await passInput.type('test', { delay: 100 }); await passInput.dispose(); // click on log in - const loginButton = await page.$('#login--form-submit'); + const loginButton = await page.$('#login--dialog-button-submit'); expect(loginButton).toBeDefined(); await loginButton.focus(); await loginButton.click({ delay: 100 }); @@ -89,8 +89,7 @@ describe('/ (Verdaccio Page)', () => { const signInButton = await page.$('button[data-testid="header--button-login"]'); await signInButton.click(); await page.waitFor(1000); - const signInDialog = await page.$('#login--form-container'); - + const signInDialog = await page.$('#login--dialog'); expect(signInDialog).not.toBeNull(); }); // diff --git a/yarn.lock b/yarn.lock index 0a755d2..3f91af0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3939,7 +3939,7 @@ commander@3.0.2: resolved "https://registry.verdaccio.org/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== -commander@^2.11.0, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1, commander@^2.9.0, commander@~2.20.0: +commander@^2.11.0, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@^2.8.1, commander@^2.9.0, commander@~2.20.0, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -9671,6 +9671,11 @@ multicast-dns@^6.0.1: dns-packet "^1.3.1" thunky "^1.0.2" +mutationobserver-shim@0.3.3: + version "0.3.3" + resolved "https://registry.verdaccio.org/mutationobserver-shim/-/mutationobserver-shim-0.3.3.tgz#65869630bc89d7bf8c9cd9cb82188cd955aacd2b" + integrity sha512-gciOLNN8Vsf7YzcqRjKzlAJ6y7e+B86u7i3KXes0xfxx/nfLmozlW1Vn+Sc9x3tPIePFgc1AeIFhtRgkqTjzDQ== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.verdaccio.org/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -11526,6 +11531,11 @@ react-dom@16.12.0: prop-types "^15.6.2" scheduler "^0.18.0" +react-hook-form@3.28.12: + version "3.28.12" + resolved "https://registry.verdaccio.org/react-hook-form/-/react-hook-form-3.28.12.tgz#387173543ab8e89b47ed076ba7ad9886848d94f4" + integrity sha512-5bHttMAehOgUHeJhbCVzXguRljFrl2ZK+ydLBqsfLCOtS4++p34vp4/Hkqle5waGojZpNbDSaGUEee24WwfGQg== + react-hot-loader@4.12.18: version "4.12.18" resolved "https://registry.verdaccio.org/react-hot-loader/-/react-hot-loader-4.12.18.tgz#a9029e34af2690d76208f9a35189d73c2dfea6a7" @@ -13687,7 +13697,7 @@ uglify-js@3.4.x: commander "~2.19.0" source-map "~0.6.1" -uglify-js@^3.1.4, uglify-js@^3.6.0: +uglify-js@^3.1.4: version "3.6.0" resolved "https://registry.verdaccio.org/uglify-js/-/uglify-js-3.6.0.tgz#704681345c53a8b2079fb6cec294b05ead242ff5" integrity sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg== @@ -13695,6 +13705,14 @@ uglify-js@^3.1.4, uglify-js@^3.6.0: commander "~2.20.0" source-map "~0.6.1" +uglify-js@^3.6.0: + version "3.7.1" + resolved "https://registry.verdaccio.org/uglify-js/-/uglify-js-3.7.1.tgz#35c7de17971a4aa7689cd2eae0a5b39bb838c0c5" + integrity sha512-pnOF7jY82wdIhATVn87uUY/FHU+MDUdPLkmGFvGoclQmeu229eTkbG5gjGGBi3R7UuYYSEeYXY/TTY5j2aym2g== + dependencies: + commander "~2.20.3" + source-map "~0.6.1" + uglifyjs-webpack-plugin@2.2.0: version "2.2.0" resolved "https://registry.verdaccio.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-2.2.0.tgz#e75bc80e7f1937f725954c9b4c5a1e967ea9d0d7"