Skip to content

Commit

Permalink
fix(payment): CHECKOUT-4852 Only show human verification message on m…
Browse files Browse the repository at this point in the history
…ount if exceeded limit
  • Loading branch information
davidchin committed May 4, 2020
1 parent 7d2f345 commit ff199b6
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 37 deletions.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"homepage": "https://github.com/bigcommerce/checkout-js#readme",
"dependencies": {
"@bigcommerce/checkout-sdk": "^1.63.0",
"@bigcommerce/checkout-sdk": "^1.63.1",
"@bigcommerce/citadel": "^2.15.1",
"@bigcommerce/form-poster": "^1.2.2",
"@bigcommerce/memoize": "^1.0.0",
Expand Down
7 changes: 6 additions & 1 deletion src/app/payment/Payment.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ describe('Payment', () => {
});
});

it('reloads checkout object if unable to submit order due to spam protection error', () => {
it('reloads checkout object if unable to submit order due to spam protection error', async () => {
jest.spyOn(checkoutService, 'loadCheckout')
.mockResolvedValue(checkoutState);

Expand All @@ -599,9 +599,14 @@ describe('Payment', () => {

const container = mount(<PaymentTest { ...defaultProps } />);

await new Promise(resolve => process.nextTick(resolve));

container.update();
container.find('ErrorModal Button').simulate('click');

expect(checkoutService.loadCheckout)
.toHaveBeenCalled();
expect(container.find(PaymentForm).prop('didExceedSpamLimit'))
.toBeTruthy();
});
});
6 changes: 6 additions & 0 deletions src/app/payment/Payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ interface WithCheckoutPaymentProps {
}

interface PaymentState {
didExceedSpamLimit: boolean;
isReady: boolean;
selectedMethod?: PaymentMethod;
shouldDisableSubmit: { [key: string]: boolean };
Expand All @@ -65,6 +66,7 @@ interface PaymentState {

class Payment extends Component<PaymentProps & WithCheckoutPaymentProps & WithLanguageProps, PaymentState> {
state: PaymentState = {
didExceedSpamLimit: false,
isReady: false,
shouldDisableSubmit: {},
validationSchemas: {},
Expand Down Expand Up @@ -134,6 +136,7 @@ class Payment extends Component<PaymentProps & WithCheckoutPaymentProps & WithLa
} = this.props;

const {
didExceedSpamLimit,
isReady,
selectedMethod = defaultMethod,
shouldDisableSubmit,
Expand Down Expand Up @@ -162,6 +165,7 @@ class Payment extends Component<PaymentProps & WithCheckoutPaymentProps & WithLa
{ ...rest }
defaultGatewayId={ defaultMethod.gateway }
defaultMethodId={ defaultMethod.id }
didExceedSpamLimit={ didExceedSpamLimit }
isUsingMultiShipping={ isUsingMultiShipping }
methods={ methods }
onMethodSelect={ this.setSelectedMethod }
Expand Down Expand Up @@ -298,6 +302,8 @@ class Payment extends Component<PaymentProps & WithCheckoutPaymentProps & WithLa
// Reload the checkout object to get the latest `shouldExecuteSpamCheck` value,
// which will in turn make `SpamProtectionField` visible again.
if (body.type === 'spam_protection_expired' || body.type === 'spam_protection_failed') {
this.setState({ didExceedSpamLimit: true });

await loadCheckout();
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/app/payment/PaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface PaymentFormProps {
availableStoreCredit?: number;
defaultGatewayId?: string;
defaultMethodId: string;
didExceedSpamLimit?: boolean;
isEmbedded?: boolean;
isTermsConditionsRequired?: boolean;
isUsingMultiShipping?: boolean;
Expand Down Expand Up @@ -60,6 +61,7 @@ export interface HostedWidgetPaymentMethodValues {

const PaymentForm: FunctionComponent<PaymentFormProps & FormikProps<PaymentFormValues> & WithLanguageProps> = ({
availableStoreCredit = 0,
didExceedSpamLimit,
isEmbedded,
isPaymentDataRequired,
isTermsConditionsRequired,
Expand All @@ -79,7 +81,10 @@ const PaymentForm: FunctionComponent<PaymentFormProps & FormikProps<PaymentFormV
values,
}) => {
if (shouldExecuteSpamCheck) {
return <SpamProtectionField />;
return <SpamProtectionField
didExceedSpamLimit={ didExceedSpamLimit }
onUnhandledError={ onUnhandledError }
/>;
}

return (
Expand Down
93 changes: 91 additions & 2 deletions src/app/payment/SpamProtectionField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createCheckoutService, CheckoutService } from '@bigcommerce/checkout-sdk';
import { render } from 'enzyme';
import { createCheckoutService, CheckoutService, StandardError } from '@bigcommerce/checkout-sdk';
import { mount, render } from 'enzyme';
import React, { FunctionComponent } from 'react';

import { CheckoutProvider } from '../checkout';
Expand Down Expand Up @@ -27,4 +27,93 @@ describe('SpamProtectionField', () => {
expect(render(<SpamProtectionTest />))
.toMatchSnapshot();
});

it('notifies parent component if unable to verify', async () => {
const handleError = jest.fn();
const error = new Error('Unknown error');

jest.spyOn(checkoutService, 'executeSpamCheck')
.mockRejectedValue(error);

const component = mount(<SpamProtectionTest onUnhandledError={ handleError } />);

await new Promise(resolve => process.nextTick(resolve));
component.update();

expect(handleError)
.toHaveBeenCalledWith(error);
});

it('does not notify parent component if unable to verify because of cancellation by user', async () => {
const handleError = jest.fn();
const error = new Error('Unknown error');

(error as StandardError).type = 'spam_protection_challenge_not_completed';

jest.spyOn(checkoutService, 'executeSpamCheck')
.mockRejectedValue(error);

const component = mount(<SpamProtectionTest onUnhandledError={ handleError } />);

await new Promise(resolve => process.nextTick(resolve));
component.update();

expect(handleError)
.not.toHaveBeenCalledWith(error);
});

describe('if have not exceeded limit', () => {
it('executes spam check on mount', () => {
mount(<SpamProtectionTest />);

expect(checkoutService.executeSpamCheck)
.toHaveBeenCalled();
});

it('does not render verify message', () => {
const component = mount(<SpamProtectionTest />);

expect(component.exists('[data-test="spam-protection-verify-button"]'))
.toBeFalsy();
});

it('renders verify message if there is error', async () => {
jest.spyOn(checkoutService, 'executeSpamCheck')
.mockRejectedValue(new Error('Unknown error'));

const component = mount(<SpamProtectionTest />);

await new Promise(resolve => process.nextTick(resolve));
component.update();

expect(component.exists('[data-test="spam-protection-verify-button"]'))
.toBeTruthy();
});
});

describe('if exceeded limit at least once', () => {
it('does not execute spam check on mount', () => {
mount(<SpamProtectionTest didExceedSpamLimit />);

expect(checkoutService.executeSpamCheck)
.not.toHaveBeenCalled();
});

it('executes spam check on click', () => {
const component = mount(<SpamProtectionTest didExceedSpamLimit />);

component.find('[data-test="spam-protection-verify-button"]')
.simulate('click');

expect(checkoutService.executeSpamCheck)
.toHaveBeenCalled();
});

it('renders verify message', () => {
const component = mount(<SpamProtectionTest didExceedSpamLimit />);

expect(component.exists('[data-test="spam-protection-verify-button"]'))
.toBeTruthy();
});
});
});
94 changes: 75 additions & 19 deletions src/app/payment/SpamProtectionField.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,108 @@
import React, { Component } from 'react';
import { CheckoutSelectors } from '@bigcommerce/checkout-sdk';
import { noop } from 'lodash';
import React, { Component, MouseEvent, ReactNode } from 'react';

import { withCheckout, CheckoutContextProps } from '../checkout';
import { TranslatedString } from '../locale';
import { LoadingOverlay } from '../ui/loading';

export interface SpamProtectionProps {
didExceedSpamLimit?: boolean;
onUnhandledError?(error: Error): void;
}

interface SpamProtectionState {
shouldShowRetryButton: boolean;
}

interface WithCheckoutSpamProtectionProps {
isExecutingSpamCheck: boolean;
verify(): void;
executeSpamCheck(): Promise<CheckoutSelectors>;
}

function mapToSpamProtectionProps(
{ checkoutService, checkoutState }: CheckoutContextProps
): WithCheckoutSpamProtectionProps {
return {
isExecutingSpamCheck: checkoutState.statuses.isExecutingSpamCheck(),
verify: checkoutService.executeSpamCheck,
executeSpamCheck: checkoutService.executeSpamCheck,
};
}

class SpamProtectionField extends Component<SpamProtectionProps & WithCheckoutSpamProtectionProps> {
class SpamProtectionField extends Component<
SpamProtectionProps & WithCheckoutSpamProtectionProps,
SpamProtectionState
> {
state = {
shouldShowRetryButton: false,
};

async componentDidMount() {
const { didExceedSpamLimit } = this.props;

if (didExceedSpamLimit) {
return;
}

this.verify();
}

render() {
const {
isExecutingSpamCheck,
verify,
} = this.props;
const { isExecutingSpamCheck } = this.props;

return (
<div className="spamProtection-container">
<LoadingOverlay isLoading={ isExecutingSpamCheck }>
<div className="spamProtection-panel optimizedCheckout-overlay">
<a
className="spamProtection-panel-message optimizedCheckout-primaryContent"
data-test="customer-continue-button"
onClick={ verify }
>
<TranslatedString
id="spam_protection.verify_action"
/>
</a>
</div>
{ this.renderContent() }
</LoadingOverlay>
</div>
);
}

private renderContent(): ReactNode {
const { didExceedSpamLimit } = this.props;
const { shouldShowRetryButton } = this.state;

if (!didExceedSpamLimit && !shouldShowRetryButton) {
return;
}

return <div className="spamProtection-panel optimizedCheckout-overlay">
<a
className="spamProtection-panel-message optimizedCheckout-primaryContent"
data-test="spam-protection-verify-button"
onClick={ this.handleRetry }
>
<TranslatedString
id="spam_protection.verify_action"
/>
</a>
</div>;
}

private async verify(): Promise<void> {
const {
executeSpamCheck,
onUnhandledError = noop,
} = this.props;

try {
await executeSpamCheck();
} catch (error) {
this.setState({ shouldShowRetryButton: true });

// Notify the parent component if the user experiences a problem other than cancelling the reCaptcha challenge.
if (error && error.type !== 'spam_protection_challenge_not_completed') {
onUnhandledError(error);
}
}
}

private handleRetry: (event: MouseEvent) => void = event => {
event.preventDefault();

this.verify();
};
}

export default withCheckout(mapToSpamProtectionProps)(SpamProtectionField);
11 changes: 1 addition & 10 deletions src/app/payment/__snapshots__/SpamProtectionField.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@ exports[`SpamProtectionField renders spam protection field 1`] = `
>
<div
class="loadingOverlay-container"
>
<div
class="spamProtection-panel optimizedCheckout-overlay"
>
<a
class="spamProtection-panel-message optimizedCheckout-primaryContent"
data-test="customer-continue-button"
/>
</div>
</div>
/>
</div>
`;

0 comments on commit ff199b6

Please sign in to comment.