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,149 +19,131 @@ const LoginPage = styled(PFLoginPage)`
} }
`; `;
class AWXLogin extends Component { function AWXLogin({ alt, i18n, isAuthenticated }) {
constructor(props) { const {
super(props); isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
this.state = { request: fetchCustomLoginInfo,
username: '', result: { logo, loginInfo },
password: '', } = useRequest(
hasAuthError: false, useCallback(async () => {
hasValidationError: false, const {
isAuthenticating: false, data: { custom_logo, custom_login_info },
isLoading: true, } = await RootAPI.read();
logo: null, const logoSrc = custom_logo
loginInfo: null,
brandName: null,
};
this.handleChangeUsername = this.handleChangeUsername.bind(this);
this.handleChangePassword = this.handleChangePassword.bind(this);
this.handleLoginButtonClick = this.handleLoginButtonClick.bind(this);
this.loadCustomLoginInfo = this.loadCustomLoginInfo.bind(this);
}
async componentDidMount() {
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}` ? `data:image/jpeg;${custom_logo}`
: loginLogoSrc; : brandLogo;
this.setState({ return {
brandName: BRAND_NAME, logo: logoSrc,
logo,
loginInfo: custom_login_info, loginInfo: custom_login_info,
}); };
} catch (err) { }, []),
this.setState({ brandName: 'AWX', logo: loginLogoSrc }); { logo: brandLogo, loginInfo: null }
} finally { );
this.setState({ isLoading: false });
}
}
async handleLoginButtonClick(event) { const {
const { username, password, isAuthenticating } = this.state; error: loginInfoError,
dismissError: dismissLoginInfoError,
} = useDismissableError(customLoginInfoError);
event.preventDefault(); useEffect(() => {
fetchCustomLoginInfo();
}, [fetchCustomLoginInfo]);
if (isAuthenticating) { const {
return; isLoading: isAuthenticating,
} error: authenticationError,
request: authenticate,
this.setState({ hasAuthError: false, isAuthenticating: true }); } = useRequest(
try { useCallback(async ({ username, password }) => {
// 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); await RootAPI.login(username, password);
} catch (err) { }, [])
if (err && err.response && err.response.status === 401) { );
this.setState({ hasValidationError: true });
} else { const {
this.setState({ hasAuthError: true }); error: authError,
} dismissError: dismissAuthError,
} finally { } = useDismissableError(authenticationError);
this.setState({ isAuthenticating: false });
} const handleSubmit = async values => {
dismissAuthError();
await authenticate(values);
};
const brandName = BrandName;
if (isCustomLoginInfoLoading) {
return null;
} }
handleChangeUsername(value) { if (isAuthenticated(document.cookie)) {
this.setState({ username: value, hasValidationError: false }); return <Redirect to="/" />;
} }
handleChangePassword(value) { let helperText;
this.setState({ password: value, hasValidationError: false }); if (authError?.response?.status === 401) {
helperText = i18n._(t`Invalid username or password. Please try again.`);
} else {
helperText = i18n._(t`There was a problem signing in. Please try again.`);
} }
render() { return (
const { <LoginPage
brandName, brandImgSrc={logo}
hasAuthError, brandImgAlt={alt || brandName}
hasValidationError, loginTitle={i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)}
username, textContent={loginInfo}
password, >
isLoading, <Formik
logo, initialValues={{
loginInfo, password: '',
} = this.state; username: '',
const { alt, i18n, isAuthenticated } = this.props; }}
onSubmit={handleSubmit}
if (isLoading) {
return null;
}
if (isAuthenticated(document.cookie)) {
return <Redirect to="/" />;
}
let helperText;
if (hasValidationError) {
helperText = i18n._(t`Invalid username or password. Please try again.`);
} else {
helperText = i18n._(t`There was a problem signing in. Please try again.`);
}
return (
<LoginPage
brandImgSrc={logo}
brandImgAlt={alt || brandName}
loginTitle={
brandName
? i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)
: ''
}
textContent={loginInfo}
> >
<LoginForm {formik => (
className={hasAuthError || hasValidationError ? 'pf-m-error' : ''} <>
helperText={helperText} <LoginForm
isValidPassword={!hasValidationError} className={authError ? 'pf-m-error' : ''}
isValidUsername={!hasValidationError} helperText={helperText}
loginButtonLabel={i18n._(t`Log In`)} isLoginButtonDisabled={isAuthenticating}
onChangePassword={this.handleChangePassword} isValidPassword={!authError}
onChangeUsername={this.handleChangeUsername} isValidUsername={!authError}
onLoginButtonClick={this.handleLoginButtonClick} loginButtonLabel={i18n._(t`Log In`)}
passwordLabel={i18n._(t`Password`)} onChangePassword={val => {
passwordValue={password} formik.setFieldValue('password', val);
showHelperText={hasAuthError || hasValidationError} dismissAuthError();
usernameLabel={i18n._(t`Username`)} }}
usernameValue={username} onChangeUsername={val => {
/> formik.setFieldValue('username', val);
</LoginPage> dismissAuthError();
); }}
} onLoginButtonClick={formik.handleSubmit}
passwordLabel={i18n._(t`Password`)}
passwordValue={formik.values.password}
showHelperText={authError}
usernameLabel={i18n._(t`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>
);
} }
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;
<AWXLogin alt="Foo Application" isAuthenticated={() => false} /> await act(async () => {
); wrapper = mountWithContexts(
const { loginHeaderLogo } = await findChildren(loginWrapper); <AWXLogin alt="Foo Application" isAuthenticated={() => false} />
);
});
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( await waitForElement(wrapper, 'LoginForm', el => el.length === 1);
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' } }); expect(
passwordInput.props().onChange({ currentTarget: { value: 'freneticpny' } }); wrapper.find('TextInputBase#pf-login-username-id').prop('value')
await waitForElement( ).toEqual('');
loginWrapper, expect(
'AWXLogin', wrapper.find('TextInputBase#pf-login-password-id').prop('value')
el => el.state('username') === 'dsarif' ).toEqual('');
); expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(true);
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 act(async () => {
}); wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')('un');
wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')('pw');
});
wrapper.update();
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('un');
); expect(
const { usernameInput, passwordInput, submitButton } = await findChildren( wrapper.find('TextInputBase#pf-login-password-id').prop('value')
loginWrapper ).toEqual('pw');
);
RootAPI.login.mockRejectedValueOnce({ response: { status: 500 } }); await act(async () => {
submitButton.simulate('click'); wrapper.find('Button[type="submit"]').invoke('onClick')();
await waitForElement( });
loginWrapper, wrapper.update();
'AWXLogin',
el => el.state('hasAuthError') === true
);
usernameInput.props().onChange({ currentTarget: { value: 'sgrimes' } }); expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(false);
passwordInput.props().onChange({ currentTarget: { value: 'ovid' } }); expect(
await waitForElement( wrapper.find('TextInput#pf-login-username-id').prop('validated')
loginWrapper, ).toEqual('error');
'AWXLogin', expect(
el => el.state('username') === 'sgrimes' wrapper.find('TextInput#pf-login-password-id').prop('validated')
); ).toEqual('error');
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('password') === 'ovid'
);
await waitForElement(
loginWrapper,
'AWXLogin',
el => el.state('hasAuthError') === true
);
submitButton.simulate('click'); await act(async () => {
await waitForElement( wrapper.find('TextInputBase#pf-login-username-id').prop('onChange')(
loginWrapper, 'foo'
'AWXLogin', );
el => el.state('hasAuthError') === false wrapper.find('TextInputBase#pf-login-password-id').prop('onChange')(
); 'bar'
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('foo');
); expect(
const { awxLogin, submitButton } = await findChildren(loginWrapper); wrapper.find('TextInputBase#pf-login-password-id').prop('value')
).toEqual('bar');
awxLogin.setState({ isAuthenticating: true }); expect(wrapper.find('FormHelperText').prop('isHidden')).toEqual(true);
submitButton.simulate('click'); expect(
submitButton.simulate('click'); wrapper.find('TextInput#pf-login-username-id').prop('validated')
expect(RootAPI.login).toHaveBeenCalledTimes(0); ).toEqual('default');
expect(
awxLogin.setState({ isAuthenticating: false }); wrapper.find('TextInput#pf-login-password-id').prop('validated')
submitButton.simulate('click'); ).toEqual('default');
submitButton.simulate('click');
expect(RootAPI.login).toHaveBeenCalledTimes(1);
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();
}); });
}); });