Merge pull request #8619 from mabashian/login-functional

Convert Login.jsx to functional component

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-12-07 17:57:03 +00:00 committed by GitHub
commit ea8ebe8a9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 278 additions and 302 deletions

View File

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

View File

@ -1,10 +1,14 @@
import React, { Component } from 'react';
import React, { useCallback, useEffect } from 'react';
import { Redirect, withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik } from 'formik';
import styled from 'styled-components';
import { LoginForm, LoginPage as PFLoginPage } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../util/useRequest';
import { RootAPI } from '../../api';
import AlertModal from '../../components/AlertModal';
import ErrorDetail from '../../components/ErrorDetail';
const loginLogoSrc = '/static/media/logo-login.svg';
@ -14,35 +18,14 @@ const LoginPage = styled(PFLoginPage)`
}
`;
class AWXLogin extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
password: '',
hasAuthError: false,
hasValidationError: false,
isAuthenticating: false,
isLoading: true,
logo: null,
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 {
function AWXLogin({ alt, i18n, isAuthenticated }) {
const {
isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
request: fetchCustomLoginInfo,
result: { brandName, logo, loginInfo },
} = useRequest(
useCallback(async () => {
const [
{
data: { custom_logo, custom_login_info },
@ -51,112 +34,123 @@ class AWXLogin extends Component {
data: { BRAND_NAME },
},
] = await Promise.all([RootAPI.read(), RootAPI.readAssetVariables()]);
const logo = custom_logo
const logoSrc = custom_logo
? `data:image/jpeg;${custom_logo}`
: loginLogoSrc;
this.setState({
return {
brandName: BRAND_NAME,
logo,
logo: logoSrc,
loginInfo: custom_login_info,
});
} catch (err) {
this.setState({ brandName: 'AWX', logo: loginLogoSrc });
} finally {
this.setState({ isLoading: false });
}
}
};
}, []),
{ brandName: null, logo: loginLogoSrc, loginInfo: null }
);
async handleLoginButtonClick(event) {
const { username, password, isAuthenticating } = this.state;
const {
error: loginInfoError,
dismissError: dismissLoginInfoError,
} = useDismissableError(customLoginInfoError);
event.preventDefault();
useEffect(() => {
fetchCustomLoginInfo();
}, [fetchCustomLoginInfo]);
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.
const {
isLoading: isAuthenticating,
error: authenticationError,
request: authenticate,
} = useRequest(
useCallback(async ({ username, password }) => {
await RootAPI.login(username, password);
} catch (err) {
if (err && err.response && err.response.status === 401) {
this.setState({ hasValidationError: true });
} else {
this.setState({ hasAuthError: true });
}, [])
);
const {
error: authError,
dismissError: dismissAuthError,
} = useDismissableError(authenticationError);
const handleSubmit = async values => {
dismissAuthError();
await authenticate(values);
};
if (isCustomLoginInfoLoading) {
return null;
}
if (isAuthenticated(document.cookie)) {
return <Redirect to="/" />;
}
let helperText;
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.`);
}
return (
<LoginPage
brandImgSrc={logo}
brandImgAlt={alt || brandName}
loginTitle={
brandName
? i18n._(t`Welcome to Ansible ${brandName}! Please Sign In.`)
: ''
}
} 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;
}
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}
textContent={loginInfo}
>
<Formik
initialValues={{
password: '',
username: '',
}}
onSubmit={handleSubmit}
>
<LoginForm
className={hasAuthError || hasValidationError ? 'pf-m-error' : ''}
helperText={helperText}
isValidPassword={!hasValidationError}
isValidUsername={!hasValidationError}
loginButtonLabel={i18n._(t`Log In`)}
onChangePassword={this.handleChangePassword}
onChangeUsername={this.handleChangeUsername}
onLoginButtonClick={this.handleLoginButtonClick}
passwordLabel={i18n._(t`Password`)}
passwordValue={password}
showHelperText={hasAuthError || hasValidationError}
usernameLabel={i18n._(t`Username`)}
usernameValue={username}
/>
</LoginPage>
);
}
{formik => (
<>
<LoginForm
className={authError ? 'pf-m-error' : ''}
helperText={helperText}
isLoginButtonDisabled={isAuthenticating}
isValidPassword={!authError}
isValidUsername={!authError}
loginButtonLabel={i18n._(t`Log In`)}
onChangePassword={val => {
formik.setFieldValue('password', val);
dismissAuthError();
}}
onChangeUsername={val => {
formik.setFieldValue('username', val);
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 { AWXLogin as _AWXLogin };

View File

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