Convert Login.jsx to functional component in preparation for social auth integration

This commit is contained in:
mabashian
2020-11-18 09:25:24 -05:00
parent 87e564026e
commit 8a8bfc5176
3 changed files with 271 additions and 308 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../testUtils/enzymeHelpers'; import { mountWithContexts } from '../testUtils/enzymeHelpers';
import App from './App'; import App from './App';
@@ -7,8 +7,11 @@ import App from './App';
jest.mock('./api'); jest.mock('./api');
describe('<App />', () => { describe('<App />', () => {
test('renders ok', () => { test('renders ok', async () => {
const wrapper = mountWithContexts(<App />); let wrapper;
await act(async () => {
wrapper = mountWithContexts(<App />);
});
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
}); });
}); });

View File

@@ -1,12 +1,17 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Redirect, withRouter } from 'react-router-dom'; import { Redirect, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik } from 'formik';
import styled from 'styled-components'; import styled from 'styled-components';
import { LoginForm, LoginPage as PFLoginPage } from '@patternfly/react-core'; import { LoginForm, LoginPage as PFLoginPage } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../util/useRequest';
import { RootAPI } from '../../api'; import { RootAPI } from '../../api';
import { BrandName } from '../../variables';
import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail';
const loginLogoSrc = '/static/media/logo-login.svg'; import brandLogo from './brand-logo.svg';
const LoginPage = styled(PFLoginPage)` const LoginPage = styled(PFLoginPage)`
& .pf-c-brand { & .pf-c-brand {
@@ -14,105 +19,60 @@ const LoginPage = styled(PFLoginPage)`
} }
`; `;
class AWXLogin extends Component { function AWXLogin({ alt, i18n, isAuthenticated }) {
constructor(props) { const {
super(props); isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
request: fetchCustomLoginInfo,
result: { logo, loginInfo },
} = useRequest(
useCallback(async () => {
const {
data: { custom_logo, custom_login_info },
} = await RootAPI.read();
const logoSrc = custom_logo
? `data:image/jpeg;${custom_logo}`
: brandLogo;
return {
logo: logoSrc,
loginInfo: custom_login_info,
};
}, []),
{ logo: brandLogo, loginInfo: null }
);
this.state = { const {
username: '', error: loginInfoError,
password: '', dismissError: dismissLoginInfoError,
hasAuthError: false, } = useDismissableError(customLoginInfoError);
hasValidationError: false,
isAuthenticating: false, useEffect(() => {
isLoading: true, fetchCustomLoginInfo();
logo: null, }, [fetchCustomLoginInfo]);
loginInfo: null,
brandName: null, const {
isLoading: isAuthenticating,
error: authenticationError,
request: authenticate,
} = useRequest(
useCallback(async ({ username, password }) => {
await RootAPI.login(username, password);
}, [])
);
const {
error: authError,
dismissError: dismissAuthError,
} = useDismissableError(authenticationError);
const handleSubmit = async values => {
dismissAuthError();
await authenticate(values);
}; };
this.handleChangeUsername = this.handleChangeUsername.bind(this); const brandName = BrandName;
this.handleChangePassword = this.handleChangePassword.bind(this);
this.handleLoginButtonClick = this.handleLoginButtonClick.bind(this);
this.loadCustomLoginInfo = this.loadCustomLoginInfo.bind(this);
}
async componentDidMount() { if (isCustomLoginInfoLoading) {
await this.loadCustomLoginInfo();
}
async loadCustomLoginInfo() {
this.setState({ isLoading: true });
try {
const [
{
data: { custom_logo, custom_login_info },
},
{
data: { BRAND_NAME },
},
] = await Promise.all([RootAPI.read(), RootAPI.readAssetVariables()]);
const logo = custom_logo
? `data:image/jpeg;${custom_logo}`
: loginLogoSrc;
this.setState({
brandName: BRAND_NAME,
logo,
loginInfo: custom_login_info,
});
} catch (err) {
this.setState({ brandName: 'AWX', logo: loginLogoSrc });
} finally {
this.setState({ isLoading: false });
}
}
async handleLoginButtonClick(event) {
const { username, password, isAuthenticating } = this.state;
event.preventDefault();
if (isAuthenticating) {
return;
}
this.setState({ hasAuthError: false, isAuthenticating: true });
try {
// note: if authentication is successful, the appropriate cookie will be set automatically
// and isAuthenticated() (the source of truth) will start returning true.
await RootAPI.login(username, password);
} catch (err) {
if (err && err.response && err.response.status === 401) {
this.setState({ hasValidationError: true });
} else {
this.setState({ hasAuthError: true });
}
} finally {
this.setState({ isAuthenticating: false });
}
}
handleChangeUsername(value) {
this.setState({ username: value, hasValidationError: false });
}
handleChangePassword(value) {
this.setState({ password: value, hasValidationError: false });
}
render() {
const {
brandName,
hasAuthError,
hasValidationError,
username,
password,
isLoading,
logo,
loginInfo,
} = this.state;
const { alt, i18n, isAuthenticated } = this.props;
if (isLoading) {
return null; return null;
} }
@@ -121,7 +81,7 @@ class AWXLogin extends Component {
} }
let helperText; let helperText;
if (hasValidationError) { if (authError?.response?.status === 401) {
helperText = i18n._(t`Invalid username or password. Please try again.`); helperText = i18n._(t`Invalid username or password. Please try again.`);
} else { } else {
helperText = i18n._(t`There was a problem signing in. Please try again.`); helperText = i18n._(t`There was a problem signing in. Please try again.`);
@@ -131,32 +91,59 @@ class AWXLogin extends Component {
<LoginPage <LoginPage
brandImgSrc={logo} brandImgSrc={logo}
brandImgAlt={alt || brandName} brandImgAlt={alt || brandName}
loginTitle={ loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)}
brandName
? i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)
: ''
}
textContent={loginInfo} textContent={loginInfo}
> >
<Formik
initialValues={{
password: '',
username: '',
}}
onSubmit={handleSubmit}
>
{formik => (
<>
<LoginForm <LoginForm
className={hasAuthError || hasValidationError ? 'pf-m-error' : ''} className={authError ? 'pf-m-error' : ''}
helperText={helperText} helperText={helperText}
isValidPassword={!hasValidationError} isLoginButtonDisabled={isAuthenticating}
isValidUsername={!hasValidationError} isValidPassword={!authError}
isValidUsername={!authError}
loginButtonLabel={i18n._(t`Log In`)} loginButtonLabel={i18n._(t`Log In`)}
onChangePassword={this.handleChangePassword} onChangePassword={val => {
onChangeUsername={this.handleChangeUsername} formik.setFieldValue('password', val);
onLoginButtonClick={this.handleLoginButtonClick} dismissAuthError();
}}
onChangeUsername={val => {
formik.setFieldValue('username', val);
dismissAuthError();
}}
onLoginButtonClick={formik.handleSubmit}
passwordLabel={i18n._(t`Password`)} passwordLabel={i18n._(t`Password`)}
passwordValue={password} passwordValue={formik.values.password}
showHelperText={hasAuthError || hasValidationError} showHelperText={authError}
usernameLabel={i18n._(t`Username`)} usernameLabel={i18n._(t`Username`)}
usernameValue={username} usernameValue={formik.values.username}
/> />
</>
)}
</Formik>
{loginInfoError && (
<AlertModal
isOpen={loginInfoError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissLoginInfoError}
>
{i18n._(
t`Failed to fetch custom login configuration settings. System defaults will be shown instead.`
)}
<ErrorDetail error={loginInfoError} />
</AlertModal>
)}
</LoginPage> </LoginPage>
); );
} }
}
export { AWXLogin as _AWXLogin };
export default withI18n()(withRouter(AWXLogin)); export default withI18n()(withRouter(AWXLogin));
export { AWXLogin as _AWXLogin };

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { RootAPI } from '../../api'; import { RootAPI } from '../../api';
import { import {
mountWithContexts, mountWithContexts,
@@ -55,11 +55,6 @@ describe('<Login />', () => {
custom_logo: 'images/foo.jpg', custom_logo: 'images/foo.jpg',
}, },
}); });
RootAPI.readAssetVariables.mockResolvedValue({
data: {
BRAND_NAME: 'AWX',
},
});
}); });
afterEach(() => { afterEach(() => {
@@ -67,27 +62,28 @@ describe('<Login />', () => {
}); });
test('initially renders without crashing', async done => { test('initially renders without crashing', async done => {
const loginWrapper = mountWithContexts( let wrapper;
<AWXLogin isAuthenticated={() => false} /> await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
const { usernameInput, passwordInput, submitButton } = await findChildren(
wrapper
); );
const {
awxLogin,
usernameInput,
passwordInput,
submitButton,
} = await findChildren(loginWrapper);
expect(usernameInput.props().value).toBe(''); expect(usernameInput.props().value).toBe('');
expect(passwordInput.props().value).toBe(''); expect(passwordInput.props().value).toBe('');
expect(awxLogin.state('hasValidationError')).toBe(false);
expect(submitButton.props().isDisabled).toBe(false); expect(submitButton.props().isDisabled).toBe(false);
expect(wrapper.find('AlertModal').length).toBe(0);
done(); done();
}); });
test('custom logo renders Brand component with correct src and alt', async done => { test('custom logo renders Brand component with correct src and alt', async done => {
const loginWrapper = mountWithContexts( let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<AWXLogin alt="Foo Application" isAuthenticated={() => false} /> <AWXLogin alt="Foo Application" isAuthenticated={() => false} />
); );
const { loginHeaderLogo } = await findChildren(loginWrapper); });
const { loginHeaderLogo } = await findChildren(wrapper);
const { alt, src } = loginHeaderLogo.props(); const { alt, src } = loginHeaderLogo.props();
expect([alt, src]).toEqual([ expect([alt, src]).toEqual([
'Foo Application', 'Foo Application',
@@ -98,195 +94,172 @@ describe('<Login />', () => {
test('default logo renders Brand component with correct src and alt', async done => { test('default logo renders Brand component with correct src and alt', async done => {
RootAPI.read.mockResolvedValue({ data: {} }); RootAPI.read.mockResolvedValue({ data: {} });
const loginWrapper = mountWithContexts( let wrapper;
<AWXLogin isAuthenticated={() => false} /> await act(async () => {
); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
const { loginHeaderLogo } = await findChildren(loginWrapper); });
const { loginHeaderLogo } = await findChildren(wrapper);
const { alt, src } = loginHeaderLogo.props(); const { alt, src } = loginHeaderLogo.props();
expect(alt).toEqual('AWX'); expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
expect(src).toContain('logo-login.svg');
done(); done();
}); });
test('default logo renders on data initialization error', async done => { test('data initialization error is properly handled', async done => {
RootAPI.read.mockRejectedValueOnce({ response: { status: 500 } }); RootAPI.read.mockRejectedValueOnce(
const loginWrapper = mountWithContexts( new Error({
<AWXLogin isAuthenticated={() => false} /> response: {
config: {
method: 'get',
url: '/api/v2',
},
data: 'An error occurred',
status: 500,
},
})
); );
const { loginHeaderLogo } = await findChildren(loginWrapper); let wrapper;
await act(async () => {
wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
});
const { loginHeaderLogo } = await findChildren(wrapper);
const { alt, src } = loginHeaderLogo.props(); const { alt, src } = loginHeaderLogo.props();
expect(alt).toEqual('AWX'); expect([alt, src]).toEqual(['AWX', 'brand-logo.svg']);
expect(src).toContain('logo-login.svg'); expect(wrapper.find('AlertModal').length).toBe(1);
done(); done();
}); });
test('state maps to un/pw input value props', async done => { test('state maps to un/pw input value props', async done => {
const loginWrapper = mountWithContexts( let wrapper;
<AWXLogin isAuthenticated={() => false} /> await act(async () => {
); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
const { usernameInput, passwordInput } = await findChildren(loginWrapper); });
usernameInput.props().onChange({ currentTarget: { value: 'un' } }); await waitForElement(wrapper, 'LoginForm', el => el.length === 1);
passwordInput.props().onChange({ currentTarget: { value: 'pw' } }); await act(async () => {
await waitForElement( wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')('un');
loginWrapper, wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')('pw');
'AWXLogin', });
el => el.state('username') === 'un' wrapper.update();
); expect(
await waitForElement( wrapper.find('TextInputBase#pf-login-username-id').prop('value')
loginWrapper, ).toEqual('un');
'AWXLogin', expect(
el => el.state('password') === 'pw' wrapper.find('TextInputBase#pf-login-password-id').prop('value')
); ).toEqual('pw');
done(); done();
}); });
test('handles input validation errors and clears on input value change', async done => { test('handles input validation errors and clears on input value change', async done => {
const formError = '.pf-c-form__helper-text.pf-m-error'; RootAPI.login.mockRejectedValueOnce(
const loginWrapper = mountWithContexts( new Error({
<AWXLogin isAuthenticated={() => false} /> response: {
); config: {
const { usernameInput, passwordInput, submitButton } = await findChildren( method: 'post',
loginWrapper url: '/api/login/',
},
data: 'An error occurred',
status: 401,
},
})
); );
RootAPI.login.mockRejectedValueOnce({ response: { status: 401 } }); let wrapper;
usernameInput.props().onChange({ currentTarget: { value: 'invalid' } }); await act(async () => {
passwordInput.props().onChange({ currentTarget: { value: 'invalid' } }); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
submitButton.simulate('click');
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('username') === 'invalid'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('password') === 'invalid'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('hasValidationError') === true
);
await waitForElement(loginWrapper, formError, el => el.length === 1);
usernameInput.props().onChange({ currentTarget: { value: 'dsarif' } });
passwordInput.props().onChange({ currentTarget: { value: 'freneticpny' } });
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('username') === 'dsarif'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('password') === 'freneticpny'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('hasValidationError') === false
);
await waitForElement(loginWrapper, formError, el => el.length === 0);
done();
}); });
await waitForElement(wrapper, 'LoginForm', el => el.length === 1);
test('handles other errors and clears on resubmit', async done => { expect(
const loginWrapper = mountWithContexts( wrapper.find('TextInputBase#pf-login-username-id').prop('value')
<AWXLogin isAuthenticated={() => false} /> ).toEqual('');
); expect(
const { usernameInput, passwordInput, submitButton } = await findChildren( wrapper.find('TextInputBase#pf-login-password-id').prop('value')
loginWrapper ).toEqual('');
); expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(true);
RootAPI.login.mockRejectedValueOnce({ response: { status: 500 } }); await act(async () => {
submitButton.simulate('click'); wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')('un');
await waitForElement( wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')('pw');
loginWrapper,
'AWXLogin',
el => el.state('hasAuthError') === true
);
usernameInput.props().onChange({ currentTarget: { value: 'sgrimes' } });
passwordInput.props().onChange({ currentTarget: { value: 'ovid' } });
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('username') === 'sgrimes'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('password') === 'ovid'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('hasAuthError') === true
);
submitButton.simulate('click');
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('hasAuthError') === false
);
done();
}); });
wrapper.update();
test('no login requests are made when already authenticating', async done => { expect(
const loginWrapper = mountWithContexts( wrapper.find('TextInputBase#pf-login-username-id').prop('value')
<AWXLogin isAuthenticated={() => false} /> ).toEqual('un');
expect(
wrapper.find('TextInputBase#pf-login-password-id').prop('value')
).toEqual('pw');
await act(async () => {
wrapper.find('Button[type="submit"]').invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(false);
expect(
wrapper.find('TextInput#pf-login-username-id').prop('validated')
).toEqual('error');
expect(
wrapper.find('TextInput#pf-login-password-id').prop('validated')
).toEqual('error');
await act(async () => {
wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')(
'foo'
); );
const { awxLogin, submitButton } = await findChildren(loginWrapper); wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')(
'bar'
);
});
wrapper.update();
awxLogin.setState({ isAuthenticating: true }); expect(
submitButton.simulate('click'); wrapper.find('TextInputBase#pf-login-username-id').prop('value')
submitButton.simulate('click'); ).toEqual('foo');
expect(RootAPI.login).toHaveBeenCalledTimes(0); expect(
wrapper.find('TextInputBase#pf-login-password-id').prop('value')
awxLogin.setState({ isAuthenticating: false }); ).toEqual('bar');
submitButton.simulate('click'); expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(true);
submitButton.simulate('click'); expect(
expect(RootAPI.login).toHaveBeenCalledTimes(1); wrapper.find('TextInput#pf-login-username-id').prop('validated')
).toEqual('default');
expect(
wrapper.find('TextInput#pf-login-password-id').prop('validated')
).toEqual('default');
done(); done();
}); });
test('submit calls api.login successfully', async done => { test('submit calls api.login successfully', async done => {
const loginWrapper = mountWithContexts( let wrapper;
<AWXLogin isAuthenticated={() => false} /> await act(async () => {
); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => false} />);
const { usernameInput, passwordInput, submitButton } = await findChildren( });
loginWrapper await waitForElement(wrapper, 'LoginForm', el => el.length === 1);
);
await act(async () => {
wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')('un');
wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')('pw');
});
wrapper.update();
await act(async () => {
wrapper.find('Button[type="submit"]').invoke('onClick')();
});
wrapper.update();
usernameInput.props().onChange({ currentTarget: { value: 'gthorpe' } });
passwordInput.props().onChange({ currentTarget: { value: 'hydro' } });
submitButton.simulate('click');
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('isAuthenticating') === true
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('isAuthenticating') === false
);
expect(RootAPI.login).toHaveBeenCalledTimes(1); expect(RootAPI.login).toHaveBeenCalledTimes(1);
expect(RootAPI.login).toHaveBeenCalledWith('gthorpe', 'hydro'); expect(RootAPI.login).toHaveBeenCalledWith('un', 'pw');
done(); done();
}); });
test('render Redirect to / when already authenticated', async done => { test('render Redirect to / when already authenticated', async done => {
const loginWrapper = mountWithContexts( let wrapper;
<AWXLogin isAuthenticated={() => true} /> await act(async () => {
); wrapper = mountWithContexts(<AWXLogin isAuthenticated={() => true} />);
await waitForElement(loginWrapper, 'Redirect', el => el.length === 1); });
await waitForElement(loginWrapper, 'Redirect', el => el.props().to === '/'); await waitForElement(wrapper, 'Redirect', el => el.length === 1);
await waitForElement(wrapper, 'Redirect', el => el.props().to === '/');
done(); done();
}); });
}); });