diff --git a/src/components/captcha/captcha.jsx b/src/components/captcha/captcha.jsx new file mode 100644 index 000000000..f6ba27efe --- /dev/null +++ b/src/components/captcha/captcha.jsx @@ -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 ( +
+ ); + } +} +Captcha.propTypes = { + onCaptchaError: PropTypes.func.isRequired, + onCaptchaLoad: PropTypes.func.isRequired, + onCaptchaSolved: PropTypes.func.isRequired +}; +module.exports = Captcha; diff --git a/src/components/join-flow/email-step.jsx b/src/components/join-flow/email-step.jsx index d73b08d89..48fe7dad7 100644 --- a/src/components/join-flow/email-step.jsx +++ b/src/components/join-flow/email-step.jsx @@ -11,7 +11,7 @@ const JoinFlowStep = require('./join-flow-step.jsx'); const FormikInput = require('../../components/formik-forms/formik-input.jsx'); const FormikCheckbox = require('../../components/formik-forms/formik-checkbox.jsx'); const InfoButton = require('../info-button/info-button.jsx'); - +const Captcha = require('../../components/captcha/captcha.jsx'); require('./join-flow-steps.scss'); class EmailStep extends React.Component { @@ -24,8 +24,8 @@ class EmailStep extends React.Component { 'validateEmailRemotelyWithCache', 'validateForm', 'setCaptchaRef', - 'captchaSolved', - 'onCaptchaLoad' + 'handleCaptchaSolved', + 'handleCaptchaLoad' ]); this.state = { captchaIsLoading: true @@ -40,43 +40,12 @@ class EmailStep extends React.Component { } // automatically start with focus on username field 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) { this.emailInput = emailInputRef; } - onCaptchaLoad () { + handleCaptchaLoad () { 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 validateEmailRemotelyWithCache (email) { @@ -116,9 +85,9 @@ class EmailStep extends React.Component { // 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). 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. this.formikBag.setSubmitting(true); this.formData['g-recaptcha-response'] = token; @@ -224,12 +193,11 @@ class EmailStep extends React.Component { name="subscribe" />
-
); diff --git a/test/unit/components/captcha.test.jsx b/test/unit/components/captcha.test.jsx new file mode 100644 index 000000000..027f721c6 --- /dev/null +++ b/test/unit/components/captcha.test.jsx @@ -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(); + 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(); + wrapper.instance().executeCaptcha(); + expect(global.grecaptcha.execute).toHaveBeenCalled(); + }); + + test('Captcha load calls props captchaOnLoad', () => { + const props = { + onCaptchaLoad: jest.fn() + }; + const wrapper = enzyme.shallow(); + 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(); + expect(wrapper.find('div.g-recaptcha')).toHaveLength(1); + }); +}); diff --git a/test/unit/components/email-step.test.jsx b/test/unit/components/email-step.test.jsx index 3f2c866d0..25b813d5a 100644 --- a/test/unit/components/email-step.test.jsx +++ b/test/unit/components/email-step.test.jsx @@ -115,9 +115,9 @@ describe('EmailStep test', () => { const formikBag = { setSubmitting: jest.fn() }; - global.grecaptcha = { - execute: jest.fn(), - render: jest.fn() + + const captchaRef = { + executeCaptcha: jest.fn() }; const formData = {item1: 'thing', item2: 'otherthing'}; const intlWrapper = shallowWithIntl( @@ -125,13 +125,12 @@ describe('EmailStep test', () => { {...defaultProps()} />); - const emailStepWrapper = intlWrapper.dive(); - emailStepWrapper.instance().onCaptchaLoad(); // to setup catpcha state + emailStepWrapper.instance().setCaptchaRef(captchaRef); emailStepWrapper.instance().handleValidSubmit(formData, formikBag); expect(formikBag.setSubmitting).toHaveBeenCalledWith(false); - expect(global.grecaptcha.execute).toHaveBeenCalled(); + expect(captchaRef.executeCaptcha).toHaveBeenCalled(); }); test('captchaSolved sets token and goes to next step', () => { @@ -141,9 +140,8 @@ describe('EmailStep test', () => { const formikBag = { setSubmitting: jest.fn() }; - global.grecaptcha = { - execute: jest.fn(), - render: jest.fn() + const captchaRef = { + executeCaptcha: jest.fn() }; const formData = {item1: 'thing', item2: 'otherthing'}; const intlWrapper = shallowWithIntl( @@ -154,11 +152,11 @@ describe('EmailStep test', () => { const emailStepWrapper = intlWrapper.dive(); // 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); const captchaToken = 'abcd'; - emailStepWrapper.instance().captchaSolved(captchaToken); + emailStepWrapper.instance().handleCaptchaSolved(captchaToken); // Make sure captchaSolved calls onNextStep with formData that has // a captcha token and left everything else in the object in place. expect(props.onNextStep).toHaveBeenCalledWith( @@ -170,29 +168,13 @@ describe('EmailStep test', () => { 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( - - ); - - const emailStepWrapper = intlWrapper.dive(); - emailStepWrapper.instance().onCaptchaLoad(); - expect(props.onCaptchaError).toHaveBeenCalled(); - }); - test('Component logs analytics', () => { const sendAnalyticsFn = jest.fn(); + const onCaptchaError = jest.fn(); mountWithIntl( ); expect(sendAnalyticsFn).toHaveBeenCalledWith('join-email'); });