Merge pull request #3765 from picklesrus/captcha-component

Move reCaptcha code to a component
This commit is contained in:
picklesrus 2020-03-27 09:18:22 -04:00 committed by GitHub
commit 7548253b17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 154 additions and 71 deletions

View file

@ -0,0 +1,76 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
class Captcha extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'setCaptchaRef',
'onCaptchaLoad',
'executeCaptcha'
]);
}
componentDidMount () {
if (window.grecaptcha) {
this.onCaptchaLoad();
} else {
// If grecaptcha doesn't exist on window, we havent loaded the captcha js yet. Load it.
// ReCaptcha calls a callback when the grecatpcha object is usable. That callback
// needs to be global so set it on the window.
window.grecaptchaOnLoad = this.onCaptchaLoad;
// Load Google ReCaptcha script.
const script = document.createElement('script');
script.async = true;
script.onerror = this.props.onCaptchaError;
script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`;
document.body.appendChild(script);
}
}
componentWillUnmount () {
window.grecaptchaOnLoad = null;
}
onCaptchaLoad () {
// Let the owner of this component do some work
// when captcha is done loading (e.g. enabling a button)
this.props.onCaptchaLoad();
this.grecaptcha = window.grecaptcha;
if (!this.grecaptcha) {
// According to the reCaptcha documentation, this callback shouldn't get
// called unless window.grecaptcha exists. This is just here to be extra defensive.
this.props.onCaptchaError();
return;
}
this.widgetId = this.grecaptcha.render(this.captchaRef,
{
callback: this.props.onCaptchaSolved,
sitekey: process.env.RECAPTCHA_SITE_KEY
},
true);
}
setCaptchaRef (ref) {
this.captchaRef = ref;
}
executeCaptcha () {
this.grecaptcha.execute(this.widgetId);
}
render () {
return (
<div
className="g-recaptcha"
data-badge="bottomright"
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
data-size="invisible"
ref={this.setCaptchaRef}
/>
);
}
}
Captcha.propTypes = {
onCaptchaError: PropTypes.func.isRequired,
onCaptchaLoad: PropTypes.func.isRequired,
onCaptchaSolved: PropTypes.func.isRequired
};
module.exports = Captcha;

View file

@ -11,7 +11,7 @@ const JoinFlowStep = require('./join-flow-step.jsx');
const FormikInput = require('../../components/formik-forms/formik-input.jsx'); const FormikInput = require('../../components/formik-forms/formik-input.jsx');
const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx'); const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx');
const InfoButton = require('../info-button/info-button.jsx'); const InfoButton = require('../info-button/info-button.jsx');
const Captcha = require('../../components/captcha/captcha.jsx');
require('./join-flow-steps.scss'); require('./join-flow-steps.scss');
class EmailStep extends React.Component { class EmailStep extends React.Component {
@ -24,8 +24,8 @@ class EmailStep extends React.Component {
'validateEmailRemotelyWithCache', 'validateEmailRemotelyWithCache',
'validateForm', 'validateForm',
'setCaptchaRef', 'setCaptchaRef',
'captchaSolved', 'handleCaptchaSolved',
'onCaptchaLoad' 'handleCaptchaLoad'
]); ]);
this.state = { this.state = {
captchaIsLoading: true captchaIsLoading: true
@ -40,43 +40,12 @@ class EmailStep extends React.Component {
} }
// automatically start with focus on username field // automatically start with focus on username field
if (this.emailInput) this.emailInput.focus(); if (this.emailInput) this.emailInput.focus();
if (window.grecaptcha) {
this.onCaptchaLoad();
} else {
// If grecaptcha doesn't exist on window, we havent loaded the captcha js yet. Load it.
// ReCaptcha calls a callback when the grecatpcha object is usable. That callback
// needs to be global so set it on the window.
window.grecaptchaOnLoad = this.onCaptchaLoad;
// Load Google ReCaptcha script.
const script = document.createElement('script');
script.async = true;
script.onerror = this.props.onCaptchaError;
script.src = `https://www.recaptcha.net/recaptcha/api.js?onload=grecaptchaOnLoad&render=explicit&hl=${window._locale}`;
document.body.appendChild(script);
}
}
componentWillUnmount () {
window.grecaptchaOnLoad = null;
} }
handleSetEmailRef (emailInputRef) { handleSetEmailRef (emailInputRef) {
this.emailInput = emailInputRef; this.emailInput = emailInputRef;
} }
onCaptchaLoad () { handleCaptchaLoad () {
this.setState({captchaIsLoading: false}); this.setState({captchaIsLoading: false});
this.grecaptcha = window.grecaptcha;
if (!this.grecaptcha) {
// According to the reCaptcha documentation, this callback shouldn't get
// called unless window.grecaptcha exists. This is just here to be extra defensive.
this.props.onCaptchaError();
return;
}
this.widgetId = this.grecaptcha.render(this.captchaRef,
{
callback: this.captchaSolved,
sitekey: process.env.RECAPTCHA_SITE_KEY
},
true);
} }
// simple function to memoize remote requests for usernames // simple function to memoize remote requests for usernames
validateEmailRemotelyWithCache (email) { validateEmailRemotelyWithCache (email) {
@ -116,9 +85,9 @@ class EmailStep extends React.Component {
// Change set submitting to false so that if the user clicks out of // Change set submitting to false so that if the user clicks out of
// the captcha, the button is clickable again (instead of a disabled button with a spinner). // the captcha, the button is clickable again (instead of a disabled button with a spinner).
this.formikBag.setSubmitting(false); this.formikBag.setSubmitting(false);
this.grecaptcha.execute(this.widgetId); this.captchaRef.executeCaptcha();
} }
captchaSolved (token) { handleCaptchaSolved (token) {
// Now thatcaptcha is done, we can tell Formik we're submitting. // Now thatcaptcha is done, we can tell Formik we're submitting.
this.formikBag.setSubmitting(true); this.formikBag.setSubmitting(true);
this.formData['g-recaptcha-response'] = token; this.formData['g-recaptcha-response'] = token;
@ -224,12 +193,11 @@ class EmailStep extends React.Component {
name="subscribe" name="subscribe"
/> />
</div> </div>
<div <Captcha
className="g-recaptcha"
data-badge="bottomright"
data-sitekey={process.env.RECAPTCHA_SITE_KEY}
data-size="invisible"
ref={this.setCaptchaRef} ref={this.setCaptchaRef}
onCaptchaError={this.props.onCaptchaError}
onCaptchaLoad={this.handleCaptchaLoad}
onCaptchaSolved={this.handleCaptchaSolved}
/> />
</JoinFlowStep> </JoinFlowStep>
); );

View file

@ -0,0 +1,57 @@
const React = require('react');
const enzyme = require('enzyme');
const Captcha = require('../../../src/components/captcha/captcha.jsx');
describe('Captcha test', () => {
global.grecaptcha = {
execute: jest.fn(),
render: jest.fn()
};
test('Captcha load calls props captchaOnLoad', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().onCaptchaLoad();
expect(global.grecaptcha.render).toHaveBeenCalled();
expect(props.onCaptchaLoad).toHaveBeenCalled();
});
test('Captcha execute calls grecatpcha execute', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().executeCaptcha();
expect(global.grecaptcha.execute).toHaveBeenCalled();
});
test('Captcha load calls props captchaOnLoad', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.shallow(<Captcha
{...props}
/>);
wrapper.instance().onCaptchaLoad();
expect(global.grecaptcha.render).toHaveBeenCalled();
expect(props.onCaptchaLoad).toHaveBeenCalled();
});
test('Captcha renders the div google wants', () => {
const props = {
onCaptchaLoad: jest.fn()
};
const wrapper = enzyme.mount(<Captcha
{...props}
/>);
expect(wrapper.find('div.g-recaptcha')).toHaveLength(1);
});
});

View file

@ -115,9 +115,9 @@ describe('EmailStep test', () => {
const formikBag = { const formikBag = {
setSubmitting: jest.fn() setSubmitting: jest.fn()
}; };
global.grecaptcha = {
execute: jest.fn(), const captchaRef = {
render: jest.fn() executeCaptcha: jest.fn()
}; };
const formData = {item1: 'thing', item2: 'otherthing'}; const formData = {item1: 'thing', item2: 'otherthing'};
const intlWrapper = shallowWithIntl( const intlWrapper = shallowWithIntl(
@ -125,13 +125,12 @@ describe('EmailStep test', () => {
{...defaultProps()} {...defaultProps()}
/>); />);
const emailStepWrapper = intlWrapper.dive(); const emailStepWrapper = intlWrapper.dive();
emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state emailStepWrapper.instance().setCaptchaRef(captchaRef);
emailStepWrapper.instance().handleValidSubmit(formData, formikBag); emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
expect(formikBag.setSubmitting).toHaveBeenCalledWith(false); expect(formikBag.setSubmitting).toHaveBeenCalledWith(false);
expect(global.grecaptcha.execute).toHaveBeenCalled(); expect(captchaRef.executeCaptcha).toHaveBeenCalled();
}); });
test('captchaSolved sets token and goes to next step', () => { test('captchaSolved sets token and goes to next step', () => {
@ -141,9 +140,8 @@ describe('EmailStep test', () => {
const formikBag = { const formikBag = {
setSubmitting: jest.fn() setSubmitting: jest.fn()
}; };
global.grecaptcha = { const captchaRef = {
execute: jest.fn(), executeCaptcha: jest.fn()
render: jest.fn()
}; };
const formData = {item1: 'thing', item2: 'otherthing'}; const formData = {item1: 'thing', item2: 'otherthing'};
const intlWrapper = shallowWithIntl( const intlWrapper = shallowWithIntl(
@ -154,11 +152,11 @@ describe('EmailStep test', () => {
const emailStepWrapper = intlWrapper.dive(); const emailStepWrapper = intlWrapper.dive();
// Call these to setup captcha. // Call these to setup captcha.
emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state emailStepWrapper.instance().setCaptchaRef(captchaRef); // to setup catpcha state
emailStepWrapper.instance().handleValidSubmit(formData, formikBag); emailStepWrapper.instance().handleValidSubmit(formData, formikBag);
const captchaToken = 'abcd'; const captchaToken = 'abcd';
emailStepWrapper.instance().captchaSolved(captchaToken); emailStepWrapper.instance().handleCaptchaSolved(captchaToken);
// Make sure captchaSolved calls onNextStep with formData that has // Make sure captchaSolved calls onNextStep with formData that has
// a captcha token and left everything else in the object in place. // a captcha token and left everything else in the object in place.
expect(props.onNextStep).toHaveBeenCalledWith( expect(props.onNextStep).toHaveBeenCalledWith(
@ -170,29 +168,13 @@ describe('EmailStep test', () => {
expect(formikBag.setSubmitting).toHaveBeenCalledWith(true); expect(formikBag.setSubmitting).toHaveBeenCalledWith(true);
}); });
test('Captcha load error calls error function', () => {
const props = {
onCaptchaError: jest.fn()
};
// Set this to null to force an error.
global.grecaptcha = null;
const intlWrapper = shallowWithIntl(
<EmailStep
{...defaultProps()}
{...props}
/>
);
const emailStepWrapper = intlWrapper.dive();
emailStepWrapper.instance().onCaptchaLoad();
expect(props.onCaptchaError).toHaveBeenCalled();
});
test('Component logs analytics', () => { test('Component logs analytics', () => {
const sendAnalyticsFn = jest.fn(); const sendAnalyticsFn = jest.fn();
const onCaptchaError = jest.fn();
mountWithIntl( mountWithIntl(
<EmailStep <EmailStep
sendAnalytics={sendAnalyticsFn} sendAnalytics={sendAnalyticsFn}
onCaptchaError={onCaptchaError}
/>); />);
expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email'); expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email');
}); });