Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Click to Pay - OTP 'Resend code' fix #2353

Merged
merged 5 commits into from
Oct 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chatty-doors-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@adyen/adyen-web': patch
---

Fixes an issue with CtPOneTimePassword getting updates to the input element reference it relies upon
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { h } from 'preact';
import { render, screen, waitFor } from '@testing-library/preact';
import CoreProvider from '../../../../../../core/Context/CoreProvider';
import ClickToPayProvider from '../../../context/ClickToPayProvider';
import { IClickToPayService } from '../../../services/types';
import { mock } from 'jest-mock-extended';
import { Resources } from '../../../../../../core/Context/Resources';
import Language from '../../../../../../language';
import CtPOneTimePasswordInput from './CtPOneTimePasswordInput';
import userEvent from '@testing-library/user-event';

const customRender = (ui, { clickToPayService = mock<IClickToPayService>(), configuration = {} } = {}) => {
return render(
<CoreProvider i18n={new Language()} loadingContext="test" resources={new Resources()}>
<ClickToPayProvider
clickToPayService={clickToPayService}
isStandaloneComponent={true}
amount={{ value: 5000, currency: 'USD' }}
onSetStatus={jest.fn()}
configuration={configuration}
onError={jest.fn()}
onSubmit={jest.fn()}
setClickToPayRef={jest.fn()}
>
{ui}
</ClickToPayProvider>
</CoreProvider>
);
};

describe('Click to Pay - CtPOneTimePasswordInput', () => {
test('should resend OTP when clicking on "Resend" button and focus back on the input', async () => {
const user = userEvent.setup();
const ctpServiceMock = mock<IClickToPayService>();
const onResendCodeMock = jest.fn();

customRender(
<CtPOneTimePasswordInput
isValidatingOtp={false}
hideResendOtpButton={false}
disabled={false}
onSetInputHandlers={jest.fn()}
onPressEnter={jest.fn()}
onChange={jest.fn()}
onResendCode={onResendCodeMock}
/>,
{ clickToPayService: ctpServiceMock }
);

const resendOtpLink = await screen.findByRole('link', { name: 'Resend code' });
const otpInput = screen.getByLabelText('One time code', { exact: false });

await user.click(resendOtpLink);

expect(onResendCodeMock).toHaveBeenCalledTimes(1);
expect(otpInput).toHaveFocus();
expect(screen.getByText('Code resent')).toBeVisible();
expect(ctpServiceMock.startIdentityValidation).toHaveBeenCalledTimes(1);
});

test('should resend OTP when clicking on "Resend" button and NOT focus back on the input', async () => {
const user = userEvent.setup();
const ctpServiceMock = mock<IClickToPayService>();
const configuration = {
disableOtpAutoFocus: true
};

const onResendCodeMock = jest.fn();

customRender(
<CtPOneTimePasswordInput
isValidatingOtp={false}
hideResendOtpButton={false}
disabled={false}
onSetInputHandlers={jest.fn()}
onPressEnter={jest.fn()}
onChange={jest.fn()}
onResendCode={onResendCodeMock}
/>,
{ clickToPayService: ctpServiceMock, configuration }
);

const resendOtpLink = await screen.findByRole('link', { name: 'Resend code' });
const otpInput = screen.getByLabelText('One time code', { exact: false });

await user.click(resendOtpLink);

expect(onResendCodeMock).toHaveBeenCalledTimes(1);
expect(otpInput).not.toHaveFocus();
expect(screen.getByText('Code resent')).toBeVisible();
expect(ctpServiceMock.startIdentityValidation).toHaveBeenCalledTimes(1);
});

test('should focus on the OTP input once the component is loaded', async () => {
const user = userEvent.setup({ delay: 100 });
customRender(
<CtPOneTimePasswordInput
isValidatingOtp={false}
hideResendOtpButton={false}
disabled={false}
onSetInputHandlers={jest.fn()}
onPressEnter={jest.fn()}
onChange={jest.fn()}
onResendCode={jest.fn()}
/>
);

const otpInput = await screen.findByLabelText('One time code', { exact: false });

await user.keyboard('654321');

expect(otpInput).toHaveValue('654321');
expect(otpInput).toHaveFocus();
});

test('should NOT focus on the OTP input once the component is loaded', async () => {
const user = userEvent.setup({ delay: 100 });
const configuration = {
disableOtpAutoFocus: true
};
customRender(
<CtPOneTimePasswordInput
isValidatingOtp={false}
hideResendOtpButton={false}
disabled={false}
onSetInputHandlers={jest.fn()}
onPressEnter={jest.fn()}
onChange={jest.fn()}
onResendCode={jest.fn()}
/>,
{ configuration }
);

const otpInput = await screen.findByLabelText('One time code', { exact: false });

await user.keyboard('654321');

expect(otpInput).toHaveValue('');
expect(otpInput).not.toHaveFocus();
});

test('should trigger callback when pressing ENTER while OTP input is focused', async () => {
const user = userEvent.setup({ delay: 100 });
const onPressEnterMock = jest.fn();

customRender(
<CtPOneTimePasswordInput
isValidatingOtp={false}
hideResendOtpButton={false}
disabled={false}
onSetInputHandlers={jest.fn()}
onPressEnter={onPressEnterMock}
onChange={jest.fn()}
onResendCode={jest.fn()}
/>
);

await waitFor(() => expect(screen.queryByLabelText('One time code', { exact: false })).toBeVisible());
await user.keyboard('[Enter]');

expect(onPressEnterMock).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const CtPOneTimePasswordInput = (props: CtPOneTimePasswordInputProps): h.JSX.Ele
rules: otpValidationRules
});
const otpInputHandlersRef = useRef<CtPOneTimePasswordInputHandlers>({ validateInput: null });
const [inputRef, setInputRef] = useState<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [isOtpFielDirty, setIsOtpFieldDirty] = useState<boolean>(false);

const validateInput = useCallback(() => {
Expand All @@ -59,10 +59,10 @@ const CtPOneTimePasswordInput = (props: CtPOneTimePasswordInputProps): h.JSX.Ele
}, [data.otp]);

useEffect(() => {
if (!disableOtpAutoFocus && inputRef) {
inputRef.focus();
if (!disableOtpAutoFocus && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef, disableOtpAutoFocus]);
}, [inputRef.current, disableOtpAutoFocus]);

useEffect(() => {
otpInputHandlersRef.current.validateInput = validateInput;
Expand All @@ -73,10 +73,10 @@ const CtPOneTimePasswordInput = (props: CtPOneTimePasswordInputProps): h.JSX.Ele
setData('otp', '');
setResendOtpError(null);
if (!disableOtpAutoFocus) {
inputRef.focus();
inputRef.current.focus();
}
props.onResendCode();
}, [props.onResendCode, inputRef, disableOtpAutoFocus]);
}, [props.onResendCode, inputRef.current, disableOtpAutoFocus]);

const handleOnResendOtpError = useCallback(
(errorCode: string) => {
Expand Down Expand Up @@ -126,7 +126,9 @@ const CtPOneTimePasswordInput = (props: CtPOneTimePasswordInputProps): h.JSX.Ele
onBlur={handleChangeFor('otp', 'blur')}
onKeyUp={handleOnKeyUp}
onKeyPress={handleOnKeyPress}
onCreateRef={setInputRef}
setRef={(ref: HTMLInputElement) => {
inputRef.current = ref;
}}
/>
</Field>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const Field: FunctionalComponent<FieldProps> = props => {
{helper && <span className={'adyen-checkout__helper-text'}>{helper}</span>}
</Fragment>
);
}, [label, errorMessage]);
}, [label, errorMessage, labelEndAdornment, helper]);

const renderInputRelatedElements = useCallback(() => {
return (
Expand Down
19 changes: 7 additions & 12 deletions packages/lib/src/components/internal/FormFields/InputBase.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { h } from 'preact';
import { MutableRef, useCallback, useEffect, useRef } from 'preact/hooks';
import { h, RefCallback } from 'preact';
import { useCallback } from 'preact/hooks';
import classNames from 'classnames';
import { ARIA_ERROR_SUFFIX } from '../../../core/Errors/constants';
import Language from '../../../language';
Expand All @@ -17,24 +17,19 @@ export interface InputBaseProps extends h.JSX.HTMLAttributes {
value?: string;
name?: string;
checked?: boolean;
setRef?: (ref: MutableRef<EventTarget>) => void;
setRef?: RefCallback<HTMLInputElement>;
trimOnBlur?: boolean;
i18n?: Language;
label?: string;
onCreateRef?(reference: HTMLInputElement): void;
onBlurHandler?: h.JSX.GenericEventHandler<HTMLInputElement>;
onFocusHandler?: h.JSX.GenericEventHandler<HTMLInputElement>;
maxlength?: number | null;
addContextualElement?: boolean;
}

export default function InputBase({ onCreateRef, ...props }: InputBaseProps) {
export default function InputBase({ setRef, ...props }: InputBaseProps) {
const { autoCorrect, classNameModifiers, isInvalid, isValid, readonly = null, spellCheck, type, uniqueId, disabled } = props;
const className = props.className as string;
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
onCreateRef?.(inputRef.current);
}, [inputRef.current, onCreateRef]);

/**
* To avoid confusion with misplaced/misdirected onChange handlers - InputBase only accepts onInput, onBlur & onFocus handlers.
Expand Down Expand Up @@ -97,7 +92,7 @@ export default function InputBase({ onCreateRef, ...props }: InputBaseProps) {
);

// Don't spread classNameModifiers etc to input element (it ends up as an attribute on the element itself)
const { classNameModifiers: cnm, uniqueId: uid, isInvalid: iiv, isValid: iv, ...newProps } = props;
const { classNameModifiers: cnm, uniqueId: uid, isInvalid: iiv, isValid: iv, addContextualElement: ace, ...newProps } = props;

return (
<input
Expand All @@ -117,7 +112,7 @@ export default function InputBase({ onCreateRef, ...props }: InputBaseProps) {
onKeyUp={handleKeyUp}
onKeyPress={handleKeyPress}
disabled={disabled}
ref={inputRef}
ref={setRef}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, h, RefObject } from 'preact';
import { Component, h } from 'preact';
import useCoreContext from '../../../core/Context/useCoreContext';
import Field from '../FormFields/Field';
import { checkIbanStatus, isValidHolder } from './validate';
Expand Down Expand Up @@ -46,7 +46,7 @@ const ibanErrorObj: GenericError = {
};

class IbanInput extends Component<IbanInputProps, IbanInputState> {
private ibanNumber: RefObject<any>;
private ibanNumber: HTMLInputElement;

constructor(props) {
super(props);
Expand Down
Loading