Skip to content

Commit

Permalink
refactor: improve captcha component and add tests (#36)
Browse files Browse the repository at this point in the history
* refactor: improve recaptcha logic
- allows us to reuse same google recaptcha script (load only once)
- use raw loader for vite to source the javascript as string

* feat: init test cases for recaptcha functions and component
* feat: complete test case for captcha component
* fix: improve test cases
* fix: extend custom property for global window object

Co-authored-by: Micah Thomas <micah.thomas@bigcommerce.com>
  • Loading branch information
bc-marco and bc-micah authored Jul 27, 2022
1 parent 76b4b12 commit bd256a0
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 118 deletions.
12 changes: 6 additions & 6 deletions apps/storefront/src/components/ThemeFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,24 @@ export class ThemeFrame extends Component<ThemeFrameProps, ThemeFrameState> {
return null
}

const doc = this.getIframeDocument()
if (doc == null) {
const iframeDocument = this.getIframeDocument()
if (iframeDocument == null) {
return null
}

if (doc.body && this.props.bodyRef) {
if (iframeDocument.body && this.props.bodyRef) {
// @ts-ignore - we are intentionally setting ref passed from parent
this.props.bodyRef.current = doc.body
this.props.bodyRef.current = iframeDocument.body
}

return createPortal(
<ThemeFrameContext.Provider value={doc}>
<ThemeFrameContext.Provider value={iframeDocument}>
<CacheProvider value={this.state.emotionCache}>
<CssBaseline />
{this.props.children}
</CacheProvider>
</ThemeFrameContext.Provider>,
doc.body,
iframeDocument.body,
)
}

Expand Down
85 changes: 85 additions & 0 deletions apps/storefront/src/components/captcha/Captcha.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
beforeAll,
afterEach,
describe,
it,
expect,
vi,
} from 'vitest'
import {
ThemeFrame,
} from '../ThemeFrame'
import {
render, screen,
} from '../../utils/test-utils'
import {
Captcha, loadCaptchaScript, loadCaptchaWidgetHandlers,
} from './Captcha'

declare global {
interface Window {
INITIALIZE_CAPTCHA_testid?: Function
}
}

const TEST_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'
const CAPTCHA_URL = 'https://www.google.com/recaptcha/api.js?render=explicit'
const INIT_BROWSER:{head:string, body:string} = {
head: '', body: '',
}

beforeAll(() => {
INIT_BROWSER.head = document.head.innerHTML
INIT_BROWSER.body = document.body.innerHTML
})

afterEach(() => {
window.INITIALIZE_CAPTCHA_testid = undefined
document.head.innerHTML = INIT_BROWSER.head
document.body.innerHTML = INIT_BROWSER.body
})

describe('loadCaptchaScript', () => {
it('should inject recaptcha script to Document', () => {
loadCaptchaScript(document)
const [recaptchaScript] = document.getElementsByTagName('script')
expect(recaptchaScript.src).toBe(CAPTCHA_URL)
})
})

describe('loadCaptchaWidgetHandlers', () => {
it('should inject script', () => {
loadCaptchaWidgetHandlers(document, 'testid')
const [recaptchaHandlersScript] = document.getElementsByTagName('script')
expect(recaptchaHandlersScript.innerHTML.length).toBeGreaterThan(0)
})
it('should replace text by id', () => {
loadCaptchaWidgetHandlers(document, 'testid')
const [recaptchaHandlersScript] = document.getElementsByTagName('script')
expect(recaptchaHandlersScript.innerHTML).toMatch('testid')
})
})

describe('Captcha', () => {
it('should render the captcha wrapper', () => {
vi.useFakeTimers()
render(
<ThemeFrame title="test-frame">
<Captcha
siteKey={TEST_SITE_KEY}
theme="dark"
size="normal"
/>
</ThemeFrame>,
)
vi.advanceTimersToNextTimer()
const iframe: HTMLIFrameElement = screen.getByTitle('test-frame')
const iframeDocument = iframe.contentDocument as Document
const [captchaWrapper] = iframeDocument.body.getElementsByTagName('div')

expect(captchaWrapper.id).toMatch('widget')
expect(captchaWrapper.dataset.sitekey).toBe(TEST_SITE_KEY)
expect(captchaWrapper.dataset.theme).toBe('dark')
expect(captchaWrapper.dataset.size).toBe('normal')
})
})
130 changes: 130 additions & 0 deletions apps/storefront/src/components/captcha/Captcha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import {
Component,
ContextType,
} from 'react'
import {
ThemeFrameContext,
} from '@/components/ThemeFrame'
import FRAME_HANDLER_CODE from './frameCaptchaCode.js?raw'

const CAPTCHA_URL = 'https://www.google.com/recaptcha/api.js?render=explicit'
const CAPTCHA_VARIABLES: Record<string, string> = {
PREFIX: '',
PARENT_ORIGIN: window.location.origin,
CAPTCHA_SUCCESS: 'captcha-success',
CAPTCHA_ERROR: 'captcha-error',
CAPTCHA_EXPIRED: 'captcha-expired',
}

export interface CaptchaProps {
siteKey: string;
size?: 'compact' | 'normal';
theme?: 'dark' | 'light';
onSuccess?: () => void;
onError?: () => void;
onExpired?: () => void;
}

export function loadCaptchaScript(iframeDocument: Document) {
if (iframeDocument.head.querySelector(`script[src="${CAPTCHA_URL}"]`) === null) {
const captchaScript = iframeDocument.createElement('script')
captchaScript.src = CAPTCHA_URL
iframeDocument.head.appendChild(captchaScript)
}
}

export function loadCaptchaWidgetHandlers(iframeDocument: Document, widgetId: string) {
let code = FRAME_HANDLER_CODE

CAPTCHA_VARIABLES.PREFIX = widgetId
const variableNames = Object.keys(CAPTCHA_VARIABLES)
for (let i = 0; i < variableNames.length; i += 1) {
const variableName = variableNames[i]
code = code.replace(
RegExp(variableName, 'g'),
CAPTCHA_VARIABLES[variableName],
)
}

const handlerScript = iframeDocument.createElement('script')
handlerScript.innerHTML = code
iframeDocument.head.appendChild(handlerScript)
}

export function generateWidgetId() {
return `widget_${Date.now()}`
}

export class Captcha extends Component<CaptchaProps> {
static contextType = ThemeFrameContext

declare context: ContextType<typeof ThemeFrameContext>

_initialized: boolean

_widgetId: string

constructor(props: CaptchaProps) {
super(props)

this._widgetId = generateWidgetId()
this._initialized = false
}

componentDidMount() {
this.initializeCaptchaInFrame()
}

componentWillUnmount() {
if (this._initialized) {
window.removeEventListener('message', this.onMessage)
}
}

onMessage = (event: MessageEvent) => {
if (event?.data?.startsWith(this._widgetId)) {
const message = event.data.slice(this._widgetId.length)
const data = JSON.parse(message)
switch (data.type) {
case CAPTCHA_VARIABLES.CAPTCHA_SUCCESS:
this.props.onSuccess?.()
break

case CAPTCHA_VARIABLES.CAPTCHA_ERROR:
this.props.onError?.()
break

case CAPTCHA_VARIABLES.CAPTCHA_EXPIRED:
this.props.onExpired?.()
break

default:
break
}
}
}

initializeCaptchaInFrame() {
const iframeDocument = this.context
if (iframeDocument === null || this._initialized) {
return
}

loadCaptchaScript(iframeDocument)
loadCaptchaWidgetHandlers(iframeDocument, this._widgetId)
window.addEventListener('message', this.onMessage, false)

this._initialized = true
}

render() {
return (
<div
id={this._widgetId}
data-sitekey={this.props.siteKey}
data-theme={this.props.theme}
data-size={this.props.size}
/>
)
}
}
44 changes: 44 additions & 0 deletions apps/storefront/src/components/captcha/frameCaptchaCode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable */

window.INITIALIZE_CAPTCHA_PREFIX = function () {
if (window.grecaptcha === undefined) {
return;
}

if (window.WIDGET_TIMER_PREFIX !== null) {
window.clearInterval(window.WIDGET_TIMER_PREFIX);
window.WIDGET_TIMER_PREFIX = null;
}

var sendMessage = function (eventType, payload) {
window.parent.postMessage(
"PREFIX" +
JSON.stringify({
type: eventType,
payload: payload,
}),
"PARENT_ORIGIN"
);
};

window.grecaptcha.render(
"PREFIX",
{
callback: function (token) {
sendMessage("CAPTCHA_SUCCESS", token);
},
"error-callback": function () {
sendMessage("CAPTCHA_ERROR", null);
},
"expired-callback": function () {
sendMessage("CAPTCHA_EXPIRED", null);
},
},
true
);
};

window.WIDGET_TIMER_PREFIX = window.setInterval(
window.INITIALIZE_CAPTCHA_PREFIX,
250
);
106 changes: 0 additions & 106 deletions apps/storefront/src/components/form/Captcha.tsx

This file was deleted.

Loading

0 comments on commit bd256a0

Please sign in to comment.