diff --git a/package.json b/package.json index b5cfff4..f62d000 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@hookform/resolvers": "^3.1.1", + "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.14.1", "@mui/lab": "^5.0.0-alpha.137", "@mui/material": "^5.14.1", @@ -57,7 +57,7 @@ "react-cookie": "^4.1.1", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", - "react-hook-form": "^7.45.2", + "react-hook-form": "^7.45.4", "react-popper": "^2.3.0", "react-router-dom": "^6.14.2", "uuid": "^9.0.0", @@ -97,7 +97,6 @@ "@types/testing-library__dom": "^7.5.0", "@types/testing-library__react": "^10.2.0", "@types/uuid": "^9.0.2", - "@types/yup": "^0.32.0", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", "@vitejs/plugin-react-swc": "^3.3.2", diff --git a/src/components/forms/InputFormControl.tsx b/src/components/forms/InputFormControl.tsx new file mode 100644 index 0000000..b9aff37 --- /dev/null +++ b/src/components/forms/InputFormControl.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react' +import { Control, Controller, FieldValues, Path } from 'react-hook-form' +import FormControl, { FormControlProps } from '@mui/material/FormControl' +import FormHelperText, { + FormHelperTextProps, +} from '@mui/material/FormHelperText' +import InputLabel, { InputLabelProps } from '@mui/material/InputLabel' +import OutlinedInput, { OutlinedInputProps } from '@mui/material/OutlinedInput' +import toTitleCase from '@/utils/toTitleCase' + +type InputFormControlProps = { + control: Control + name: Path + label?: string + FormControlProps?: FormControlProps + FormHelperTextProps?: FormHelperTextProps + InputLabelProps?: InputLabelProps + InputProps?: OutlinedInputProps +} + +const InputFormControl = ({ + control, + name, + label = toTitleCase(name), + FormControlProps, + FormHelperTextProps, + InputLabelProps, + InputProps, +}: InputFormControlProps) => { + const { + slotProps: { input: inputSlotProps = {}, root: rootSlotProps = {} } = {}, + } = InputProps || {} + + return ( + + ( + <> + + {label} + + + + {error?.message || ' '} + + + )} + /> + + ) +} + +export default InputFormControl diff --git a/src/components/forms/SubmitButton.test.tsx b/src/components/forms/SubmitButton.test.tsx index 2604185..8bc772e 100644 --- a/src/components/forms/SubmitButton.test.tsx +++ b/src/components/forms/SubmitButton.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import SubmitButton from './SubmitButton' +import SubmitButton from '@/components/forms/SubmitButton' describe('SubmitButton', () => { it('renders without crashing', () => { @@ -14,15 +14,19 @@ describe('SubmitButton', () => { expect(buttonElement).toHaveAttribute('type', 'submit') expect(buttonElement).toHaveClass('MuiButton-contained') expect(buttonElement).toHaveClass('MuiButton-containedPrimary') - expect(buttonElement.textContent).toBe('Save') + expect(buttonElement.textContent).toBe('Submit') }) it('applies passed props correctly', () => { - render() + render( + + Save + + ) const buttonElement = screen.getByRole('button') expect(buttonElement).toHaveClass('MuiButton-outlined') expect(buttonElement).toHaveClass('MuiButton-outlinedSecondary') - expect(buttonElement.textContent).toBe('Submit') + expect(buttonElement.textContent).toBe('Save') }) it('is disabled when disabled prop is passed', () => { diff --git a/src/components/forms/SubmitButton.tsx b/src/components/forms/SubmitButton.tsx index 56cb010..454dcb6 100644 --- a/src/components/forms/SubmitButton.tsx +++ b/src/components/forms/SubmitButton.tsx @@ -3,21 +3,21 @@ * @module sbom-harbor-ui/components/forms/SubmitButton */ import Button, { ButtonProps } from '@mui/material/Button' +import { PropsWithChildren } from 'react' type InputProps = { disabled?: boolean - label?: string } & ButtonProps -const SubmitButton = ({ label, ...props }: InputProps) => ( - -) +const SubmitButton = ({ + children = 'Submit', + ...props +}: PropsWithChildren) => SubmitButton.defaultProps = { color: 'primary', - label: 'Save', + label: 'Submit', + size: 'large', type: 'submit', variant: 'contained', } diff --git a/src/views/Dashboard/Team/TeamForm.tsx b/src/views/Dashboard/Team/TeamForm.tsx index 0d9f3ad..d230494 100644 --- a/src/views/Dashboard/Team/TeamForm.tsx +++ b/src/views/Dashboard/Team/TeamForm.tsx @@ -408,7 +408,11 @@ const TeamForm = () => { > Cancel - + diff --git a/src/views/SignIn/SignIn.components.tsx b/src/views/SignIn/SignIn.components.tsx index 166fe8d..eba781b 100644 --- a/src/views/SignIn/SignIn.components.tsx +++ b/src/views/SignIn/SignIn.components.tsx @@ -2,7 +2,7 @@ * Styled Components for the SignIn view component. * @module sbom-harbor-ui/views/SignIn/SignIn.components */ -import { styled } from '@mui/material/styles' +import { styled, useTheme } from '@mui/material/styles' import Box, { BoxProps } from '@mui/material/Box' import MuiFormControlLabel, { FormControlLabelProps, @@ -63,3 +63,90 @@ export const FormControlLabel = styled( fontSize: theme.typography.body2.fontSize, }, })) + +export const SignInGraphic: React.FC = () => { + const { + palette: { + primary: { main: fill }, + }, + } = useTheme() + + return ( + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/views/SignIn/SignIn.test.tsx b/src/views/SignIn/SignIn.test.tsx new file mode 100644 index 0000000..b3ac9f5 --- /dev/null +++ b/src/views/SignIn/SignIn.test.tsx @@ -0,0 +1,67 @@ +// src/views/SignIn/useSignIn.test.ts +import { JSXElementConstructor, ReactElement } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SignIn from '@/views/SignIn/SignIn' + +function setup( + jsx: ReactElement< + typeof SignIn, + string | JSXElementConstructor + > +) { + return { + user: userEvent.setup(), + ...render(jsx), + } +} + +jest.mock('react-router-dom', () => ({ + useNavigate: jest.fn(), +})) + +describe('SignIn', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should submit correct form data in the SignIn page', async () => { + const mockSubmit = jest.fn() + + jest.mock('react-hook-form', () => ({ + useForm: () => ({ + control: { + email: '', + password: '', + }, + handleSubmit: mockSubmit, + }), + })) + + // Setup to render the SignIn page + const { user } = setup() + + // Type the email into the email field + await user.type( + screen.getByRole('textbox', { name: /email/i }), + 'user@example.com' + ) + + // Type the password into the password field + await user.type( + screen.getByRole('textbox', { name: /password/i }), + 'password' + ) + + // Save the form + await user.click(screen.getByRole('button', { name: /^login$/i })) + + // Wait for form submission to complete + waitFor(() => { + expect(mockSubmit).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'password', + }) + }) + }) +}) diff --git a/src/views/SignIn/SignIn.tsx b/src/views/SignIn/SignIn.tsx index 1a830b1..0f32130 100644 --- a/src/views/SignIn/SignIn.tsx +++ b/src/views/SignIn/SignIn.tsx @@ -2,32 +2,25 @@ * The view at the /login route that renders the sign in form. * @module sbom-harbor-ui/views/SignIn/SignIn */ -import * as React from 'react' -import { Link } from 'react-router-dom' -import { Controller } from 'react-hook-form' +import { memo, ReactNode } from 'react' import { useTheme } from '@mui/material/styles' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import Checkbox from '@mui/material/Checkbox' -import FormControl from '@mui/material/FormControl' -import FormHelperText from '@mui/material/FormHelperText' -import InputLabel from '@mui/material/InputLabel' -import MuiLink from '@mui/material/Link' -import OutlinedInput from '@mui/material/OutlinedInput' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' -import PasswordVisibilityToggle from '@/components/PasswordVisibilityToggle' +import InputFormControl from '@/components/forms/InputFormControl' import LinearIndeterminate from '@/components/mui/LinearLoadingBar' +import PasswordVisibilityToggle from '@/components/PasswordVisibilityToggle' +import SubmitButton from '@/components/forms/SubmitButton' import BlankLayout from '@/layouts/BlankLayout' -import { useSignIn } from '@/views/SignIn/useSignIn' -import SignInGraphic from '@/views/SignIn/SignInGraphic' +import { FormData, useSignIn } from '@/views/SignIn/useSignIn' import { BoxWrapper, CenteredFlexBox, - FormControlLabel, LoginIllustrationWrapper, RightWrapper, + SignInGraphic, VerticalCenteredFlexBox, } from '@/views/SignIn/SignIn.components' @@ -35,21 +28,31 @@ import { * Component that renders the page containing the sign in form. * @returns {JSX.Element} component that renders the the sign in form. */ -const LoginPage = () => { +const SignIn = () => { const theme = useTheme() const hidden = useMediaQuery(theme.breakpoints.down('md')) const { control, - errors, loading, + handleClickFederatedSignIn, + handleSubmit, showPassword, setShowPassword, - handleClickFederatedSignIn, - onSubmit, - handleSubmitHookForm, } = useSignIn() + const ShowPasswordButton = memo( + Object.assign( + () => ( + + ), + { displayName: 'ShowPasswordButton' } + ) as React.FC + ) + return ( {!hidden && ( @@ -95,126 +98,55 @@ const LoginPage = () => { Please sign-in to your account. -
- - - Email - - ( - - )} - /> - - {errors?.email?.message || ' '} - - - - - Password - - ( - - } - aria-describedby={ - errors.password ? 'password-error' : undefined - } - /> - )} - /> - - {errors?.password?.message || ' '} - - - + control={control} + name="email" + InputProps={{ + placeholder: 'admin@cms.gov', + }} + /> + + control={control} + name="password" + InputProps={{ + endAdornment: , }} + /> + - } /> - - Forgot Password? - - - - - - -
- {loading && } + Login +
+ + or... + + + + {/* End of Form */} + + {loading && ( + + )} @@ -222,8 +154,6 @@ const LoginPage = () => { ) } -LoginPage.getLayout = (page: React.ReactNode) => ( - {page} -) +SignIn.getLayout = (page: ReactNode) => {page} -export default LoginPage +export default SignIn diff --git a/src/views/SignIn/SignInGraphic.tsx b/src/views/SignIn/SignInGraphic.tsx deleted file mode 100644 index e8323d1..0000000 --- a/src/views/SignIn/SignInGraphic.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @module sbom-harbor-ui/views/SignIn/SignInGraphic - */ -import { useTheme } from '@mui/material/styles' - -const SignInGraphic = () => { - const theme = useTheme() - - return ( - - - - - - - - - - - - - - - - - - - ) -} - -export default SignInGraphic diff --git a/src/views/SignIn/useSignIn.test.ts b/src/views/SignIn/useSignIn.test.tsx similarity index 91% rename from src/views/SignIn/useSignIn.test.ts rename to src/views/SignIn/useSignIn.test.tsx index f729768..df7e3cd 100644 --- a/src/views/SignIn/useSignIn.test.ts +++ b/src/views/SignIn/useSignIn.test.tsx @@ -1,11 +1,11 @@ // src/views/SignIn/useSignIn.test.ts -import { renderHook, act, waitFor } from '@testing-library/react' -import { useNavigate } from 'react-router-dom' import { Auth } from 'aws-amplify' +import { useNavigate } from 'react-router-dom' +import { renderHook, act, waitFor } from '@testing-library/react' import loginUser from '@/actions/loginUser' import useAlert from '@/hooks/useAlert' import { useAuthDispatch } from '@/hooks/useAuth' -import { useSignIn } from './useSignIn' +import { useSignIn } from '@/views/SignIn/useSignIn' jest.mock('@/actions/loginUser') jest.mock('react-router-dom', () => ({ @@ -30,10 +30,7 @@ describe('useSignIn', () => { const { result } = renderHook(useSignIn) await act(async () => { - result.current.onSubmit({ - email: 'test@test.com', - password: 'password', - }) + result.current.handleSubmit() }) waitFor(() => { @@ -77,10 +74,7 @@ describe('useSignIn', () => { const { result } = renderHook(useSignIn) await act(async () => { - result.current.onSubmit({ - email: 'test@test.com', - password: 'password', - }) + result.current.handleSubmit() }) waitFor(() => { diff --git a/src/views/SignIn/useSignIn.ts b/src/views/SignIn/useSignIn.ts index dfa4bb1..cc9da5a 100644 --- a/src/views/SignIn/useSignIn.ts +++ b/src/views/SignIn/useSignIn.ts @@ -1,18 +1,18 @@ /** * @module sbom-harbor-ui/views/SignIn/useSignIn.ts */ -import { useCallback, useMemo, useState } from 'react' +import { Auth } from 'aws-amplify' +import { FederatedSignInOptions } from '@aws-amplify/auth/lib/types' +import { BaseSyntheticEvent, useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { useForm } from 'react-hook-form' import { yupResolver } from '@hookform/resolvers/yup' import * as yup from 'yup' -import { Auth } from 'aws-amplify' -import { type FederatedSignInOptions } from '@aws-amplify/auth/lib/types' -import { useNavigate } from 'react-router-dom' import loginUser from '@/actions/loginUser' import useAlert from '@/hooks/useAlert' import { useAuthDispatch } from '@/hooks/useAuth' -interface FormData { +export interface FormData { email: string password: string } @@ -22,9 +22,15 @@ const defaultValues: FormData = { password: '', } -const schema = yup.object().shape({ - email: yup.string().email().required(), - password: yup.string().min(5).required(), +const validationSchema = yup.object().shape({ + email: yup + .string() + .email('Invalid email format') + .required('Email is required'), + password: yup + .string() + .min(8, 'Password must be at least 8 characters') + .required('Password is required'), }) export const useSignIn = () => { @@ -36,13 +42,26 @@ export const useSignIn = () => { const [showPassword, setShowPassword] = useState(false) const { + clearErrors, control, - handleSubmit: handleSubmitHookForm, + formState, formState: { errors }, + handleSubmit: handleSubmitInternal, + getFieldState, + getValues, + register, + reset, + resetField, + setError, + setFocus, + setValue, + trigger, + unregister, + watch, } = useForm({ defaultValues, mode: 'onBlur', - resolver: yupResolver(schema), + resolver: yupResolver(validationSchema), }) const handleClickFederatedSignIn = async () => { @@ -82,11 +101,25 @@ export const useSignIn = () => { return { control, errors, + formState, loading, - showPassword, - handleSubmitHookForm, - onSubmit, + clearErrors, + getFieldState, + getValues, handleClickFederatedSignIn, + handleSubmit: handleSubmitInternal(onSubmit) satisfies ( + e?: BaseSyntheticEvent | undefined + ) => Promise, + register, + reset, + resetField, + setError, + setFocus, + setValue, + trigger, + unregister, + watch, + showPassword, setShowPassword, } } diff --git a/yarn.lock b/yarn.lock index 445c7d6..f0820ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4394,12 +4394,12 @@ __metadata: languageName: node linkType: hard -"@hookform/resolvers@npm:^3.1.1": - version: 3.1.1 - resolution: "@hookform/resolvers@npm:3.1.1" +"@hookform/resolvers@npm:^3.2.0": + version: 3.2.0 + resolution: "@hookform/resolvers@npm:3.2.0" peerDependencies: react-hook-form: ^7.0.0 - checksum: 41da04f0995438364f129484011a84c0d4333ce1b4cb7815593f58ece51bbd88c87db9ea6b1f5d0b7ae3e47bfcdf1a3f8e2328c46800e884074048473b56d5b5 + checksum: 041692117f1915ea23ab48f0eeaa4841ffb45f849b9743315ac4b05ae860a4ceb41adc55cf3c213aefe705b6b63b725d9a128c0ae7d15f5faca162e2a94a63d1 languageName: node linkType: hard @@ -7703,15 +7703,6 @@ __metadata: languageName: node linkType: hard -"@types/yup@npm:^0.32.0": - version: 0.32.0 - resolution: "@types/yup@npm:0.32.0" - dependencies: - yup: "*" - checksum: 5b30f1118bca288d949bcd4c7e17d1425ffa320eddd751a78c895b158fae0b6cbf242ced45ad47e9e70643c9475da09a7ad004192fb6f2111791d37e6a627e73 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:^6.1.0": version: 6.1.0 resolution: "@typescript-eslint/eslint-plugin@npm:6.1.0" @@ -16884,12 +16875,12 @@ __metadata: languageName: node linkType: hard -"react-hook-form@npm:^7.45.2": - version: 7.45.2 - resolution: "react-hook-form@npm:7.45.2" +"react-hook-form@npm:^7.45.4": + version: 7.45.4 + resolution: "react-hook-form@npm:7.45.4" peerDependencies: react: ^16.8.0 || ^17 || ^18 - checksum: 023ca091f2bb1f3117d8a694871874c4a344f02c795c6a08b8f78b09b78444451375fbe921d7db4ba7cd78909b1fa5e92d19efd2bfedc43124fdc44540ac036c + checksum: 1d84c78dadf561bc9e360272c5c856c724426162e13a74929c0517c3eb8004a7f057561fff3bfd4c1121b36d6cf1437399df1dbfd01d0817b9f922503f407d8f languageName: node linkType: hard @@ -17704,7 +17695,7 @@ __metadata: "@commitlint/config-conventional": ^17.6.7 "@emotion/react": ^11.11.1 "@emotion/styled": ^11.11.0 - "@hookform/resolvers": ^3.1.1 + "@hookform/resolvers": ^3.2.0 "@mui/icons-material": ^5.14.1 "@mui/lab": ^5.0.0-alpha.137 "@mui/material": ^5.14.1 @@ -17740,7 +17731,6 @@ __metadata: "@types/testing-library__dom": ^7.5.0 "@types/testing-library__react": ^10.2.0 "@types/uuid": ^9.0.2 - "@types/yup": ^0.32.0 "@typescript-eslint/eslint-plugin": ^6.1.0 "@typescript-eslint/parser": ^6.1.0 "@vitejs/plugin-react-swc": ^3.3.2 @@ -17780,7 +17770,7 @@ __metadata: react-docgen-typescript: ^2.2.2 react-dom: ^18.2.0 react-dropzone: ^14.2.3 - react-hook-form: ^7.45.2 + react-hook-form: ^7.45.4 react-popper: ^2.3.0 react-router-dom: ^6.14.2 rimraf: ^5.0.1 @@ -20135,7 +20125,7 @@ __metadata: languageName: node linkType: hard -"yup@npm:*, yup@npm:^1.2.0": +"yup@npm:^1.2.0": version: 1.2.0 resolution: "yup@npm:1.2.0" dependencies: