Skip to content

Commit

Permalink
v6 - Click to Pay: Fixing ENTER keypress behavior (#2874)
Browse files Browse the repository at this point in the history
* draft

* draft2

* added some tests

* added test case for cards view

* clean up

* added listener to button. moved keydown event to parent div

* reverting changes

* fixed manual card entry behavior. tests. cleaned up code. changeset
  • Loading branch information
ribeiroguilherme authored Sep 30, 2024
1 parent 8ec3276 commit a4393e3
Show file tree
Hide file tree
Showing 18 changed files with 261 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-cars-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': patch
---

Click to Pay - Fixed ENTER keypress behavior
2 changes: 1 addition & 1 deletion packages/lib/config/testMocks/analyticsMock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
global.analytics = {
sendAnalytics: () => null,
setUp: () => null,
setUp: () => Promise.resolve(),
getCheckoutAttemptId: () => null,
getEventsQueue: () => null,
createAnalyticsEvent: () => null,
Expand Down
10 changes: 10 additions & 0 deletions packages/lib/src/components/Card/components/ClickToPayHolder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ const ClickToPayHolder = ({ children }: ClickToPayWrapperProps) => {
setIsCtpPrimaryPaymentMethod(false);
}, []);

const handleButtonKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
void handleOnShowCardButtonClick();
}
},
[handleOnShowCardButtonClick]
);

if (ctpState === CtpState.NotAvailable) {
return children();
}
Expand All @@ -59,6 +68,7 @@ const ClickToPayHolder = ({ children }: ClickToPayWrapperProps) => {
disabled={status === 'loading'}
label={i18n.get('ctp.manualCardEntry')}
onClick={handleOnShowCardButtonClick}
onKeyDown={handleButtonKeyDown}
/>
)}
</Fragment>
Expand Down
151 changes: 151 additions & 0 deletions packages/lib/src/components/ClickToPay/ClickToPay.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { mock } from 'jest-mock-extended';
import { render, screen } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';

import { ClickToPayElement } from './ClickToPay';
import { ClickToPayProps } from '../internal/ClickToPay/types';
import createClickToPayService from '../internal/ClickToPay/services/create-clicktopay-service';
import { ClickToPayCheckoutPayload, IClickToPayService } from '../internal/ClickToPay/services/types';
import { CtpState } from '../internal/ClickToPay/services/ClickToPayService';
import { Resources } from '../../core/Context/Resources';
import ShopperCard from '../internal/ClickToPay/models/ShopperCard';

jest.mock('../internal/ClickToPay/services/create-clicktopay-service');

Expand Down Expand Up @@ -99,3 +104,149 @@ test('should reject isAvailable if shopper account is not found', async () => {

await expect(element.isAvailable()).rejects.toBeFalsy();
});

describe('Click to Pay: ENTER keypress should perform an action only within the CtP Component and should not propagate the event up to UIElement', () => {
test('[Login form] should trigger shopper email lookup when ENTER key is pressed', async () => {
const user = userEvent.setup();

const mockCtpService = mock<IClickToPayService>();
mockCtpService.initialize.mockImplementation(() => Promise.resolve());
mockCtpService.schemes = ['visa', 'mc'];
mockCtpService.verifyIfShopperIsEnrolled.mockResolvedValue({ isEnrolled: false });
mockCtpService.subscribeOnStateChange.mockImplementation(callback => {
callback(CtpState.Login);
});

// @ts-ignore mockImplementation not inferred
createClickToPayService.mockImplementation(() => mockCtpService);

const resources = mock<Resources>();
resources.getImage.mockReturnValue((icon: string) => `https://checkout-adyen.com/${icon}`);

const onSubmitMock = jest.fn();

const element = new ClickToPayElement(global.core, {
onSubmit: onSubmitMock,
loadingContext: 'checkoutshopper.com/',
modules: { resources, analytics: global.analytics },
i18n: global.i18n
});
render(element.mount('body'));

const emailInput = await screen.findByLabelText('Email');
await user.type(emailInput, 'shopper@example.com');
await user.keyboard('[Enter]');

expect(mockCtpService.verifyIfShopperIsEnrolled).toHaveBeenCalledTimes(1);
expect(mockCtpService.verifyIfShopperIsEnrolled).toHaveBeenCalledWith({ shopperEmail: 'shopper@example.com' });
expect(onSubmitMock).not.toHaveBeenCalled();

element.unmount();
});

test('[OTP form] should trigger OTP validation when ENTER key is pressed', async () => {
const user = userEvent.setup();

const mockCtpService = mock<IClickToPayService>();
mockCtpService.initialize.mockImplementation(() => Promise.resolve());
mockCtpService.schemes = ['visa', 'mc'];
mockCtpService.finishIdentityValidation.mockResolvedValue();
mockCtpService.subscribeOnStateChange.mockImplementation(callback => {
callback(CtpState.OneTimePassword);
});

// @ts-ignore mockImplementation not inferred
createClickToPayService.mockImplementation(() => mockCtpService);

const resources = mock<Resources>();
resources.getImage.mockReturnValue((icon: string) => `https://checkout-adyen.com/${icon}`);

const onSubmitMock = jest.fn();

const element = new ClickToPayElement(global.core, {
onSubmit: onSubmitMock,
loadingContext: 'checkoutshopper.com/',
modules: { resources, analytics: global.analytics },
i18n: global.i18n
});
render(element.mount('body'));

const emailInput = await screen.findByLabelText('One time code', { exact: false });
await user.type(emailInput, '654321');
await user.keyboard('[Enter]');

expect(mockCtpService.finishIdentityValidation).toHaveBeenCalledTimes(1);
expect(mockCtpService.finishIdentityValidation).toHaveBeenCalledWith('654321');
expect(onSubmitMock).not.toHaveBeenCalled();

element.unmount();
});

test('[Card view] should trigger Click to Pay checkout when ENTER key is pressed', async () => {
const user = userEvent.setup();

const mockCtpService = mock<IClickToPayService>();
mockCtpService.initialize.mockImplementation(() => Promise.resolve());
mockCtpService.schemes = ['visa', 'mc'];
mockCtpService.subscribeOnStateChange.mockImplementation(callback => {
callback(CtpState.Ready);
});
mockCtpService.shopperCards = [
new ShopperCard(
{
srcDigitalCardId: '654321',
panLastFour: '8902',
dateOfCardCreated: '2015-10-10T09:15:00.312Z',
dateOfCardLastUsed: '2020-05-28T08:10:02.312Z',
paymentCardDescriptor: 'visa',
panExpirationMonth: '08',
panExpirationYear: '2040',
digitalCardData: {
descriptorName: 'Visa',
artUri: 'http://image.com/visa',
status: 'ACTIVE'
},
tokenId: 'xxxx-wwww'
},
'visa',
'1234566'
)
];
mockCtpService.checkout.mockResolvedValue({
srcDigitalCardId: 'xxxx',
srcCorrelationId: 'yyyy',
srcScheme: 'visa'
});

// @ts-ignore mockImplementation not inferred by Typescript
createClickToPayService.mockImplementation(() => mockCtpService);

const resources = mock<Resources>();
resources.getImage.mockReturnValue((icon: string) => `https://checkout-adyen.com/${icon}`);

const onSubmitMock = jest.fn();

const element = new ClickToPayElement(global.core, {
onSubmit: onSubmitMock,
loadingContext: 'checkoutshopper.com/',
modules: { resources, analytics: global.analytics },
i18n: global.i18n
});
render(element.mount('body'));

const button = await screen.findByRole('button', { name: /Pay/ });

// Focus on the Pay button
await user.tab();
await user.tab();
expect(button).toHaveFocus();

await user.keyboard('[Enter]');

expect(mockCtpService.checkout).toHaveBeenCalledTimes(1);
expect(mockCtpService.checkout).toHaveBeenCalledWith(mockCtpService.shopperCards[0]);
expect(onSubmitMock).toHaveBeenCalled();

element.unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ abstract class BaseElement<P extends BaseElementProps> implements IBaseElement {
this._node = node;

// Add listener for key press events, notably 'Enter' key presses
on(this._node, 'keypress', this.handleKeyPress);
on(this._node, 'keypress', this.handleKeyPress, false);

this._component = this.render();

Expand Down
6 changes: 5 additions & 1 deletion packages/lib/src/components/internal/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class Button extends Component<ButtonProps, ButtonState> {
}, delay);
};

public onKeyDown = (event: KeyboardEvent) => {
this.props.onKeyDown?.(event);
};

render({ classNameModifiers = [], disabled, href, icon, inline, label, status, variant }, { completed }) {
const { i18n } = useCoreContext();

Expand Down Expand Up @@ -78,7 +82,7 @@ class Button extends Component<ButtonProps, ButtonState> {
}

return (
<button className={buttonClasses} type="button" disabled={disabled} onClick={this.onClick}>
<button className={buttonClasses} type="button" disabled={disabled} onClick={this.onClick} onKeyDown={this.onKeyDown}>
{buttonText}
{status !== 'loading' && status !== 'redirect' && this.props.children}
</button>
Expand Down
3 changes: 3 additions & 0 deletions packages/lib/src/components/internal/Button/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { h } from 'preact';

export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'action';

export interface ButtonProps {
Expand All @@ -16,6 +18,7 @@ export interface ButtonProps {
target?: string;
rel?: string;
onClick?: (e, callbacks) => void;
onKeyDown?: (event: KeyboardEvent) => void;
}

export interface ButtonState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useEffect } from 'preact/hooks';
import { useCallback, useEffect } from 'preact/hooks';
import { CtpState } from './services/ClickToPayService';
import useClickToPayContext from './context/useClickToPayContext';
import CtPOneTimePassword from './components/CtPOneTimePassword';
Expand Down Expand Up @@ -36,12 +36,23 @@ const ClickToPayComponent = ({ onDisplayCardComponent }: ClickToPayComponentProp
}
}, [ctpState]);

/**
* We capture the ENTER keypress within the ClickToPay component because we do not want to propagate the event up to the UIElement
* UIElement would perform the payment flow (by calling .submit), which is not relevant/supported by Click to Pay
*/
const handleEnterKeyPress = useCallback((event: h.JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault(); // Prevent <form> submission if Component is placed inside a form
event.stopPropagation(); // Prevent global BaseElement keypress event to be triggered
}
}, []);

if (ctpState === CtpState.NotAvailable) {
return null;
}

return (
<CtPSection>
<CtPSection onEnterKeyPress={handleEnterKeyPress}>
{[CtpState.Loading, CtpState.ShopperIdentified].includes(ctpState) && <CtPLoader />}
{ctpState === CtpState.OneTimePassword && <CtPOneTimePassword onDisplayCardComponent={onDisplayCardComponent} />}
{ctpState === CtpState.Ready && <CtPCards onDisplayCardComponent={onDisplayCardComponent} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ const CtPCards = ({ onDisplayCardComponent }: CtPCardsProps) => {
const displayNetworkDcf = isShopperCheckingOutWithCtp && status === 'loading' && checkoutCard?.isDcfPopupEmbedded;
const displayCardCheckoutView = status !== 'loading' || !displayNetworkDcf;

const handlePayButtonKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
void doCheckout();
}
},
[doCheckout]
);

return (
<Fragment>
<Iframe name={CTP_IFRAME_NAME} height="380" width="100%" classNameModifiers={[displayNetworkDcf ? '' : 'hidden']} />
Expand All @@ -98,7 +107,6 @@ const CtPCards = ({ onDisplayCardComponent }: CtPCardsProps) => {
<Fragment>
<CtPSection.Title>{i18n.get('ctp.cards.title')}</CtPSection.Title>
<CtPSection.Text>{i18n.get('ctp.cards.subtitle')}</CtPSection.Text>

{cards.length === 0 && <div className="adyen-checkout-ctp__empty-cards">{i18n.get('ctp.emptyProfile.message')}</div>}
{cards.length === 1 && <CtPSingleCard card={cards[0]} errorMessage={getErrorLabel(errorCode, i18n)} />}
{cards.length > 1 && (
Expand All @@ -121,6 +129,7 @@ const CtPCards = ({ onDisplayCardComponent }: CtPCardsProps) => {
getImage({ imageFolder: 'components/' })(isCtpPrimaryPaymentMethod ? `${PREFIX}lock` : `${PREFIX}lock_black`)
}
onClick={doCheckout}
onKeyDown={handlePayButtonKeyDown}
/>
</Fragment>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ const CtPLogin = (): h.JSX.Element => {
}
}, [verifyIfShopperIsEnrolled, startIdentityValidation, shopperLogin, isValid, loginInputHandlers]);

const handleButtonKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
void handleOnLoginButtonClick();
}
},
[handleOnLoginButtonClick]
);

return (
<Fragment>
<CtPSection.Title endAdornment={<CtPInfo />}>{i18n.get('ctp.login.title')}</CtPSection.Title>
Expand All @@ -83,6 +92,7 @@ const CtPLogin = (): h.JSX.Element => {
onClick={() => {
void handleOnLoginButtonClick();
}}
onKeyDown={handleButtonKeyDown}
/>
</Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const CtPLoginInput = (props: CtPLoginInputProps): h.JSX.Element => {
props.onSetInputHandlers(loginInputHandlersRef.current);
}, [validateInput, props.onSetInputHandlers]);

const handleOnKeyUp = useCallback(
const handleOnKeyPress = useCallback(
(event: h.JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
void props.onPressEnter();
Expand All @@ -57,11 +57,6 @@ const CtPLoginInput = (props: CtPLoginInputProps): h.JSX.Element => {
[props.onPressEnter]
);

const handleOnKeyPress = useCallback((event: h.JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
// Prevent <form> submission if Component is placed inside an form
if (event.key === 'Enter') event.preventDefault();
}, []);

useEffect(() => {
props.onChange({ data, valid, errors, isValid });
}, [data, valid, errors]);
Expand All @@ -82,7 +77,6 @@ const CtPLoginInput = (props: CtPLoginInputProps): h.JSX.Element => {
onInput={handleChangeFor('shopperLogin', 'input')}
onBlur={handleChangeFor('shopperLogin', 'blur')}
onKeyPress={handleOnKeyPress}
onKeyUp={handleOnKeyUp}
/>
</Field>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ const CtPOneTimePassword = ({ onDisplayCardComponent }: CtPOneTimePasswordProps)
}
}, [otp, isValid, otpInputHandlers, onDisplayCardComponent]);

const handleButtonKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
void onSubmitPassword();
}
},
[onSubmitPassword]
);

const subtitleParts = i18n.get('ctp.otp.subtitle').split('%@');

return (
Expand Down Expand Up @@ -96,6 +105,7 @@ const CtPOneTimePassword = ({ onDisplayCardComponent }: CtPOneTimePasswordProps)
variant={isCtpPrimaryPaymentMethod ? 'primary' : 'secondary'}
onClick={onSubmitPassword}
status={isValidatingOtp && 'loading'}
onKeyDown={handleButtonKeyDown}
/>
</Fragment>
);
Expand Down
Loading

0 comments on commit a4393e3

Please sign in to comment.