Fix/remove this keyword from functional comps (#1968)

* Removing this keyword from functional comps

* Removing this keyword from functional comps - expanding to parent UIElements (Ach, Doku, Econtext, Boleto)

* Removing some mocks from e2e tests now we have appropriate test card

* Implementing suggestions to improve interfaces and prevent repetition
This commit is contained in:
sponglord
2023-01-30 17:43:12 +01:00
committed by GitHub
parent fc606c735c
commit 9f59620028
31 changed files with 375 additions and 330 deletions

View File

@@ -1,63 +1,24 @@
const path = require('path');
require('dotenv').config({ path: path.resolve('../../', '.env') });
import { Selector, RequestMock } from 'testcafe';
import { Selector } from 'testcafe';
import { start, getIframeSelector, getIsValid } from '../../../../utils/commonUtils';
import cu from '../../../utils/cardUtils';
import { BASE_URL, CARDS_URL } from '../../../../pages';
import { BIN_LOOKUP_VERSION, UNKNOWN_BIN_CARD } from '../../../utils/constants';
import { CARDS_URL } from '../../../../pages';
import { BCMC_CARD } from '../../../utils/constants';
const cvcSpan = Selector('.card-field .adyen-checkout__field__cvc');
const optionalCVCSpan = Selector('.card-field .adyen-checkout__field__cvc--optional');
const cvcLabel = Selector('.card-field .adyen-checkout__label__text');
const brandingIcon = Selector('.card-field .adyen-checkout__card__cardNumber__brandIcon');
const requestURL = `https://checkoutshopper-test.adyen.com/checkoutshopper/${BIN_LOOKUP_VERSION}/bin/binLookup?token=${process.env.CLIENT_KEY}`;
/**
* NOTE - we are mocking the response until such time as we have a genuine card,
* that's not in our local RegEx, that returns the properties we want to test
*/
const mockedResponse = {
brands: [
{
brand: 'bcmc', // keep as a recognised card brand (bcmc) until we have a genuine card - to avoid logo loading errors
cvcPolicy: 'hidden',
enableLuhnCheck: true,
showExpiryDate: true,
supported: true
}
],
issuingCountryCode: 'US',
requestId: null
};
const mock = RequestMock()
.onRequestTo(request => {
return request.url === requestURL && request.method === 'post';
})
.respond(
(req, res) => {
const body = JSON.parse(req.body);
mockedResponse.requestId = body.requestId;
res.setBody(mockedResponse);
},
200,
{
'Access-Control-Allow-Origin': BASE_URL
}
);
const TEST_SPEED = 1;
const iframeSelector = getIframeSelector('.card-field iframe');
const cardUtils = cu(iframeSelector);
fixture`Testing a card, as detected by a mock/binLookup, for a response that should indicate hidden cvc)`
.page(CARDS_URL)
.clientScripts('binLookup.mocks.clientScripts.js')
.requestHooks(mock);
fixture`Testing a card for a response that should indicate hidden cvc)`.page(CARDS_URL).clientScripts('binLookup.mocks.clientScripts.js');
test('Test card has hidden cvc field ' + 'then complete date and see card is valid ' + ' then delete card number and see card reset', async t => {
// Start, allow time for iframes to load
@@ -81,7 +42,7 @@ test('Test card has hidden cvc field ' + 'then complete date and see card is val
.notOk();
// Unknown card
await cardUtils.fillCardNumber(t, UNKNOWN_BIN_CARD);
await cardUtils.fillCardNumber(t, BCMC_CARD);
await t
// bcmc card icon

View File

@@ -1,4 +1,4 @@
window.cardConfig = {
type: 'scheme',
brands: ['mc', 'visa', 'amex', 'bcmc']
brands: ['mc', 'visa', 'amex', 'bcmc', 'synchrony_plcc']
};

View File

@@ -1,62 +1,23 @@
const path = require('path');
require('dotenv').config({ path: path.resolve('../../', '.env') });
import { Selector, RequestMock } from 'testcafe';
import { Selector } from 'testcafe';
import { start, getIframeSelector, getIsValid } from '../../../../utils/commonUtils';
import cu from '../../../utils/cardUtils';
import { BASE_URL, CARDS_URL } from '../../../../pages';
import { BIN_LOOKUP_VERSION, TEST_CVC_VALUE, UNKNOWN_BIN_CARD } from '../../../utils/constants';
import { CARDS_URL } from '../../../../pages';
import { SYNCHRONY_PLCC_NO_LUHN, TEST_CVC_VALUE } from '../../../utils/constants';
const brandingIcon = Selector('.card-field .adyen-checkout__card__cardNumber__brandIcon');
const dateSpan = Selector('.card-field .adyen-checkout__card__exp-date__input');
const requestURL = `https://checkoutshopper-test.adyen.com/checkoutshopper/${BIN_LOOKUP_VERSION}/bin/binLookup?token=${process.env.CLIENT_KEY}`;
/**
* NOTE - we are mocking the response until such time as we have a genuine card,
* that's not in our local RegEx, that returns the properties we want to test
*/
const mockedResponse = {
brands: [
{
brand: 'bcmc', // keep as a recognised card brand (bcmc) until we have a genuine plcc - to avoid logo loading errors
cvcPolicy: 'required',
enableLuhnCheck: true,
showExpiryDate: false,
supported: true
}
],
issuingCountryCode: 'US',
requestId: null
};
const mock = RequestMock()
.onRequestTo(request => {
return request.url === requestURL && request.method === 'post';
})
.respond(
(req, res) => {
const body = JSON.parse(req.body);
mockedResponse.requestId = body.requestId;
res.setBody(mockedResponse);
},
200,
{
'Access-Control-Allow-Origin': BASE_URL
}
);
const TEST_SPEED = 1;
const iframeSelector = getIframeSelector('.card-field iframe');
const cardUtils = cu(iframeSelector);
fixture`Testing a PLCC, as detected by a mock/binLookup, for a response that should indicate hidden expiryDate field)`
.page(CARDS_URL)
.clientScripts('plcc.clientScripts.js')
.requestHooks(mock);
fixture`Testing a PLCC, for a response that should indicate hidden expiryDate field)`.page(CARDS_URL).clientScripts('plcc.clientScripts.js');
test('Test plcc card hides date field ' + 'then complete date and see card is valid ' + ' then delete card number and see card reset', async t => {
// Start, allow time for iframes to load
@@ -66,13 +27,9 @@ test('Test plcc card hides date field ' + 'then complete date and see card is va
await t.expect(brandingIcon.getAttribute('src')).contains('nocard.svg');
// Unknown card
await cardUtils.fillCardNumber(t, UNKNOWN_BIN_CARD);
await cardUtils.fillCardNumber(t, SYNCHRONY_PLCC_NO_LUHN);
await t
// bcmc card icon
.expect(brandingIcon.getAttribute('src'))
.contains('bcmc.svg')
// hidden date field
.expect(dateSpan.filterHidden().exists)
.ok();

View File

@@ -12,8 +12,8 @@ export const AMEX_CARD = '370000000000002';
export const DUAL_BRANDED_CARD_EXCLUDED = '4001230000000004'; // dual branded visa/star
export const SYNCHRONY_PLCC_NO_LUHN = '6044100018023838';
export const SYNCHRONY_PLCC_WITH_LUHN = '6044141000018769';
export const SYNCHRONY_PLCC_NO_LUHN = '6044100018023838'; // also, no date
export const SYNCHRONY_PLCC_WITH_LUHN = '6044141000018769'; // also, no date
export const FAILS_LUHN_CARD = '4111111111111112';

View File

@@ -79,9 +79,7 @@ export class AchElement extends UIElement<AchElementProps> {
/>
) : (
<AchInput
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
onChange={this.setState}
onSubmit={this.submit}

View File

@@ -14,6 +14,7 @@ import styles from './AchInput.module.scss';
import './AchInput.scss';
import { ACHInputDataState, ACHInputProps, ACHInputStateError, ACHInputStateValid } from './types';
import StoreDetails from '../../../internal/StoreDetails';
import { ComponentMethodsRef } from '../../../types';
function validateHolderName(holderName, holderNameRequired = false) {
if (holderNameRequired) {
@@ -84,14 +85,20 @@ function AchInput(props: ACHInputProps) {
// Refs
const sfp = useRef(null);
const billingAddressRef = useRef(null);
const setAddressRef = ref => {
billingAddressRef.current = ref;
};
const [status, setStatus] = useState('ready');
this.setStatus = newStatus => {
setStatus(newStatus);
};
/** An object by which to expose 'public' members to the parent UIElement */
const achRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(achRef.current).length) {
props.setComponentRef?.(achRef.current);
}
this.showValidation = () => {
achRef.current.showValidation = () => {
// Validate SecuredFields
sfp.current.showValidation();
@@ -104,6 +111,8 @@ function AchInput(props: ACHInputProps) {
if (billingAddressRef.current) billingAddressRef.current.showValidation();
};
achRef.current.setStatus = setStatus;
useEffect(() => {
this.setFocusOn = sfp.current.setFocusOn;
this.updateStyles = sfp.current.updateStyles;
@@ -172,7 +181,7 @@ function AchInput(props: ACHInputProps) {
onChange={handleAddress}
allowedCountries={props.billingAddressAllowedCountries}
requiredFields={props.billingAddressRequiredFields}
ref={billingAddressRef}
setComponentRef={setAddressRef}
/>
)}

View File

@@ -58,4 +58,5 @@ export interface ACHInputProps {
styles?: StylesObject;
type?: string;
forceCompat?: boolean;
setComponentRef?: (ref) => void;
}

View File

@@ -16,11 +16,10 @@ export class AddressElement extends UIElement {
return (
<CoreProvider i18n={this.props.i18n} loadingContext={this.props.loadingContext}>
<Address
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
onChange={this.setState}
{...(process.env.NODE_ENV !== 'production' && { payButton: this.payButton })}
/>
</CoreProvider>
);

View File

@@ -40,7 +40,13 @@ export class BoletoElement extends UIElement {
{this.props.reference ? (
<BoletoVoucherResult ref={this.handleRef} icon={this.icon} {...this.props} />
) : (
<BoletoInput ref={this.handleRef} {...this.props} onChange={this.setState} onSubmit={this.submit} payButton={this.payButton} />
<BoletoInput
setComponentRef={this.handleRef}
{...this.props}
onChange={this.setState}
onSubmit={this.submit}
payButton={this.payButton}
/>
)}
</CoreProvider>
);

View File

@@ -8,18 +8,21 @@ import useCoreContext from '../../../../core/Context/useCoreContext';
import { BoletoInputDataState } from '../../types';
import useForm from '../../../../utils/useForm';
import { BrazilPersonalDetail } from '../../../internal/SocialSecurityNumberBrazil/BrazilPersonalDetail';
import { ComponentMethodsRef } from '../../../types';
function BoletoInput(props) {
const { i18n } = useCoreContext();
const addressRef = useRef(null);
const { handleChangeFor, triggerValidation, setSchema, setData, setValid, setErrors, data, valid, errors, isValid } = useForm<
BoletoInputDataState
>({
schema: ['firstName', 'lastName', 'socialSecurityNumber', 'billingAddress', 'shopperEmail'],
defaultData: props.data,
rules: boletoValidationRules,
formatters: boletoFormatters
});
const setAddressRef = ref => {
addressRef.current = ref;
};
const { handleChangeFor, triggerValidation, setSchema, setData, setValid, setErrors, data, valid, errors, isValid } =
useForm<BoletoInputDataState>({
schema: ['firstName', 'lastName', 'socialSecurityNumber', 'billingAddress', 'shopperEmail'],
defaultData: props.data,
rules: boletoValidationRules,
formatters: boletoFormatters
});
// Email field toggle
const [showingEmail, setShowingEmail] = useState<boolean>(false);
@@ -42,15 +45,23 @@ function BoletoInput(props) {
};
const [status, setStatus] = useState('ready');
this.setStatus = setStatus;
this.showValidation = () => {
/** An object by which to expose 'public' members to the parent UIElement */
const boletoRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(boletoRef.current).length) {
props.setComponentRef?.(boletoRef.current);
}
boletoRef.current.showValidation = () => {
triggerValidation();
if (props.billingAddressRequired) {
addressRef.current.showValidation();
}
};
boletoRef.current.setStatus = setStatus;
useEffect(() => {
const billingAddressValid = props.billingAddressRequired ? Boolean(valid.billingAddress) : true;
props.onChange({ data, valid, errors, isValid: isValid && billingAddressValid });
@@ -71,7 +82,7 @@ function BoletoInput(props) {
data={{ ...props.data.billingAddress, country: 'BR' }}
onChange={handleAddress}
requiredFields={['country', 'street', 'houseNumberOrName', 'postalCode', 'city', 'stateOrProvince']}
ref={addressRef}
setComponentRef={setAddressRef}
/>
)}

View File

@@ -50,10 +50,6 @@ export class CardElement extends UIElement<CardElementProps> {
return this;
}
public setComponentRef = ref => {
this.componentRef = ref;
};
private setClickToPayRef = ref => {
this.clickToPayRef = ref;
};

View File

@@ -27,11 +27,15 @@ import { getPartialAddressValidationRules } from '../../../internal/Address/vali
const CardInput: FunctionalComponent<CardInputProps> = props => {
const sfp = useRef(null);
const billingAddressRef = useRef(null);
const isValidating = useRef(false);
const billingAddressRef = useRef(null);
const setAddressRef = ref => {
billingAddressRef.current = ref;
};
const cardInputRef = useRef<CardInputRef>({});
// Just call once
// Just call once to create the object by which we expose the members expected by the parent Card comp
if (!Object.keys(cardInputRef.current).length) {
props.setComponentRef(cardInputRef.current);
}
@@ -430,7 +434,7 @@ const CardInput: FunctionalComponent<CardInputProps> = props => {
// For Store details
handleOnStoreDetails={setStorePaymentMethod}
// For Address
billingAddressRef={billingAddressRef}
setAddressRef={setAddressRef}
billingAddress={billingAddress}
billingAddressValidationRules={partialAddressSchema && getPartialAddressValidationRules(partialAddressCountry.current)}
partialAddressSchema={partialAddressSchema}

View File

@@ -49,7 +49,7 @@ export const CardFieldsWrapper = ({
// Address
billingAddress,
handleAddress,
billingAddressRef,
setAddressRef,
partialAddressSchema,
// For this comp (props passed through from CardInput)
amount,
@@ -171,7 +171,7 @@ export const CardFieldsWrapper = ({
onChange={handleAddress}
allowedCountries={billingAddressAllowedCountries}
requiredFields={billingAddressRequiredFields}
ref={billingAddressRef}
setComponentRef={setAddressRef}
validationRules={billingAddressValidationRules}
specifications={partialAddressSchema}
iOSFocusedField={iOSFocusedField}

View File

@@ -8,6 +8,7 @@ import { ValidationRuleResult } from '../../../../utils/Validator/ValidationRule
import Specifications from '../../../internal/Address/Specifications';
import { AddressSchema, StringObject } from '../../../internal/Address/types';
import { CbObjOnError, StylesObject } from '../../../internal/SecuredFields/lib/types';
import { ComponentMethodsRef } from '../../../types';
export interface CardInputValidState {
holderName?: boolean;
@@ -131,12 +132,10 @@ export interface CardInputState {
showSocialSecurityNumber?: boolean;
}
export interface CardInputRef {
export interface CardInputRef extends ComponentMethodsRef {
sfp?: any;
setFocusOn?: (who) => void;
showValidation?: (who) => void;
processBinLookupResponse?: (binLookupResponse: BinLookupResponse, isReset: boolean) => void;
setStatus?: any;
updateStyles?: (stylesObj: StylesObject) => void;
handleUnsupportedCard?: (errObj: CbObjOnError) => boolean;
}

View File

@@ -40,9 +40,7 @@ export class DokuElement extends UIElement {
/>
) : (
<DokuInput
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
onChange={this.setState}
onSubmit={this.submit}

View File

@@ -2,18 +2,31 @@ import { h } from 'preact';
import { useRef, useState } from 'preact/hooks';
import PersonalDetails from '../../../internal/PersonalDetails/PersonalDetails';
import useCoreContext from '../../../../core/Context/useCoreContext';
import { ComponentMethodsRef } from '../../../types';
export default function DokuInput(props) {
const personalDetailsRef = useRef(null);
const setPersonalDetailsRef = ref => {
personalDetailsRef.current = ref;
};
const { i18n } = useCoreContext();
const [status, setStatus] = useState('ready');
this.setStatus = setStatus;
this.showValidation = () => {
if (personalDetailsRef.current) personalDetailsRef.current.showValidation();
/** An object by which to expose 'public' members to the parent UIElement */
const dokuRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(dokuRef.current).length) {
props.setComponentRef?.(dokuRef.current);
}
dokuRef.current.showValidation = () => {
personalDetailsRef.current?.showValidation();
};
dokuRef.current.setStatus = setStatus;
return (
<div className="adyen-checkout__doku-input__field">
<PersonalDetails
@@ -21,7 +34,7 @@ export default function DokuInput(props) {
requiredFields={['firstName', 'lastName', 'shopperEmail']}
onChange={props.onChange}
namePrefix="doku"
ref={personalDetailsRef}
setComponentRef={setPersonalDetailsRef}
/>
{props.showPayButton && props.payButton({ status, label: i18n.get('confirmPurchase') })}

View File

@@ -55,9 +55,7 @@ export class EcontextElement extends UIElement<EcontextElementProps> {
/>
) : (
<EcontextInput
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
onChange={this.setState}
onSubmit={this.submit}

View File

@@ -5,6 +5,7 @@ import useCoreContext from '../../../../core/Context/useCoreContext';
import { econtextValidationRules } from '../../validate';
import { PersonalDetailsSchema } from '../../../../types';
import './EcontextInput.scss';
import { ComponentMethodsRef } from '../../../types';
interface EcontextInputProps {
personalDetailsRequired?: boolean;
@@ -16,17 +17,28 @@ interface EcontextInputProps {
[key: string]: any;
}
export default function EcontextInput({ personalDetailsRequired = true, data, onChange, showPayButton, payButton }: EcontextInputProps) {
export default function EcontextInput({ personalDetailsRequired = true, data, onChange, showPayButton, payButton, ...props }: EcontextInputProps) {
const personalDetailsRef = useRef(null);
const setPersonalDetailsRef = ref => {
personalDetailsRef.current = ref;
};
const { i18n } = useCoreContext();
const [status, setStatus] = useState('ready');
this.setStatus = setStatus;
this.showValidation = () => {
if (personalDetailsRef.current) personalDetailsRef.current.showValidation();
/** An object by which to expose 'public' members to the parent UIElement */
const econtextRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(econtextRef.current).length) {
props.setComponentRef?.(econtextRef.current);
}
econtextRef.current.showValidation = () => {
personalDetailsRef.current?.showValidation();
};
econtextRef.current.setStatus = setStatus;
return (
<div className="adyen-checkout__econtext-input__field">
{!!personalDetailsRequired && (
@@ -35,7 +47,7 @@ export default function EcontextInput({ personalDetailsRequired = true, data, on
requiredFields={['firstName', 'lastName', 'telephoneNumber', 'shopperEmail']}
onChange={onChange}
namePrefix="econtext"
ref={personalDetailsRef}
setComponentRef={setPersonalDetailsRef}
validationRules={econtextValidationRules}
/>
)}

View File

@@ -16,11 +16,10 @@ export class PersonalDetailsElement extends UIElement {
return (
<CoreProvider i18n={this.props.i18n} loadingContext={this.props.loadingContext}>
<PersonalDetails
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
onChange={this.setState}
{...(process.env.NODE_ENV !== 'production' && { payButton: this.payButton })}
/>
</CoreProvider>
);

View File

@@ -130,10 +130,7 @@ export class UIElement<P extends UIElementProps = any> extends BaseElement<P> im
}
private submitAdditionalDetails(data): Promise<void> {
return this._parentInstance.session
.submitDetails(data)
.then(this.handleResponse)
.catch(this.handleError);
return this._parentInstance.session.submitDetails(data).then(this.handleResponse).catch(this.handleError);
}
protected handleError = (error: AdyenCheckoutError): void => {
@@ -228,6 +225,10 @@ export class UIElement<P extends UIElementProps = any> extends BaseElement<P> im
return this.elementRef._parentInstance.update(options);
}
public setComponentRef = ref => {
this.componentRef = ref;
};
/**
* Get the current validation status of the element
*/

View File

@@ -5,7 +5,7 @@ import CoreProvider from '../../../core/Context/CoreProvider';
import { OpenInvoiceProps } from '../../internal/OpenInvoice/types';
import { AddressSpecifications } from '../../internal/Address/types';
export interface OpenInvoiceContainerProps extends Partial<OpenInvoiceProps>{
export interface OpenInvoiceContainerProps extends Partial<OpenInvoiceProps> {
consentCheckboxLabel?: h.JSX.Element;
billingAddressRequiredFields?: string[];
billingAddressSpecification?: AddressSpecifications;
@@ -87,9 +87,7 @@ export default class OpenInvoiceContainer extends UIElement<OpenInvoiceContainer
return (
<CoreProvider i18n={this.props.i18n} loadingContext={this.props.loadingContext}>
<OpenInvoice
ref={ref => {
this.componentRef = ref;
}}
setComponentRef={this.setComponentRef}
{...this.props}
{...this.state}
onChange={this.setState}

View File

@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useEffect, useMemo } from 'preact/hooks';
import { Fragment, h } from 'preact';
import { useEffect, useMemo, useRef } from 'preact/hooks';
import Fieldset from '../FormFields/Fieldset';
import ReadOnlyAddress from './components/ReadOnlyAddress';
import { getAddressValidationRules } from './validate';
@@ -11,9 +11,21 @@ import useForm from '../../../utils/useForm';
import Specifications from './Specifications';
import { ADDRESS_SCHEMA, FALLBACK_VALUE } from './constants';
import { getMaxLengthByFieldAndCountry } from '../../../utils/validator-utils';
import useCoreContext from '../../../core/Context/useCoreContext';
import { ComponentMethodsRef } from '../../types';
export default function Address(props: AddressProps) {
const { i18n } = useCoreContext();
const { label = '', requiredFields, visibility, iOSFocusedField = null } = props;
/** An object by which to expose 'public' members to the parent UIElement */
const addressRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(addressRef.current).length) {
props.setComponentRef?.(addressRef.current);
}
const specifications = useMemo(() => new Specifications(props.specifications), [props.specifications]);
const requiredFieldsSchema = specifications.getAddressSchemaForCountryFlat(props.countryCode).filter(field => requiredFields.includes(field));
@@ -25,6 +37,11 @@ export default function Address(props: AddressProps) {
formatters: addressFormatters
});
// Expose method expected by (parent) Address.tsx
addressRef.current.showValidation = () => {
triggerValidation();
};
/**
* For iOS: iOSFocusedField is the name of the element calling for other elements to be disabled
* - so if it is set (meaning we are in iOS *and* an input has been focussed) only enable the field that corresponds to this element
@@ -81,8 +98,6 @@ export default function Address(props: AddressProps) {
props.onChange({ data: processedData, valid, errors, isValid });
}, [data, valid, errors, isValid]);
this.showValidation = triggerValidation;
if (visibility === 'hidden') return null;
if (visibility === 'readOnly') return <ReadOnlyAddress data={data} label={label} />;
@@ -118,9 +133,13 @@ export default function Address(props: AddressProps) {
const addressSchema = specifications.getAddressSchemaForCountry(data.country);
return (
<Fieldset classNameModifiers={[label]} label={label}>
{addressSchema.map(field => (field instanceof Array ? getWrapper(field) : getComponent(field, {})))}
</Fieldset>
<Fragment>
<Fieldset classNameModifiers={[label]} label={label}>
{addressSchema.map(field => (field instanceof Array ? getWrapper(field) : getComponent(field, {})))}
</Fieldset>
{/* Needed to easily test when showValidation is called */}
{process.env.NODE_ENV !== 'production' && props.showPayButton && props.payButton({ label: i18n.get('continue') })}
</Fragment>
);
}

View File

@@ -21,6 +21,9 @@ export interface AddressProps {
visibility?: string;
overrideSchema?: AddressSpecifications;
iOSFocusedField?: string;
payButton?: (obj) => {};
showPayButton?: boolean;
setComponentRef?: (ref) => void;
}
export interface AddressStateError {

View File

@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useEffect } from 'preact/hooks';
import { useEffect, useRef } from 'preact/hooks';
import Fieldset from '../FormFields/Fieldset';
import Field from '../FormFields/Field';
import ReadOnlyCompanyDetails from './ReadOnlyCompanyDetails';
@@ -9,6 +9,7 @@ import useCoreContext from '../../../core/Context/useCoreContext';
import { getFormattedData } from './utils';
import { CompanyDetailsSchema, CompanyDetailsProps } from './types';
import useForm from '../../../utils/useForm';
import { ComponentMethodsRef } from '../../types';
const companyDetailsSchema = ['name', 'registrationNumber'];
@@ -21,22 +22,34 @@ export default function CompanyDetails(props: CompanyDetailsProps) {
defaultData: props.data
});
/** An object by which to expose 'public' members to the parent UIElement */
const companyDetailsRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(companyDetailsRef.current).length) {
props.setComponentRef?.(companyDetailsRef.current);
}
// Expose method expected by (parent) Address.tsx
companyDetailsRef.current.showValidation = () => {
triggerValidation();
};
const generateFieldName = (name: string): string => `${namePrefix ? `${namePrefix}.` : ''}${name}`;
const eventHandler = (mode: string): Function => (e: Event): void => {
const { name } = e.target as HTMLInputElement;
const key = name.split(`${namePrefix}.`).pop();
const eventHandler =
(mode: string): Function =>
(e: Event): void => {
const { name } = e.target as HTMLInputElement;
const key = name.split(`${namePrefix}.`).pop();
handleChangeFor(key, mode)(e);
};
handleChangeFor(key, mode)(e);
};
useEffect(() => {
const formattedData = getFormattedData(data);
props.onChange({ data: formattedData, valid, errors, isValid });
}, [data, valid, errors, isValid]);
this.showValidation = triggerValidation;
if (visibility === 'hidden') return null;
if (visibility === 'readOnly') return <ReadOnlyCompanyDetails {...props} data={data} />;

View File

@@ -16,6 +16,7 @@ export interface CompanyDetailsProps {
readonly?: boolean;
ref?: any;
validationRules?: ValidatorRules;
setComponentRef?: (ref) => void;
}
export interface ReadOnlyCompanyDetailsProps {

View File

@@ -5,6 +5,11 @@ import { mock } from 'jest-mock-extended';
import { OpenInvoiceProps } from './types';
import { FieldsetVisibility } from '../../../types';
let componentRef;
const setComponentRef = ref => {
componentRef = ref;
};
const defaultProps = {
onChange: () => {},
data: { personalDetails: {}, billingAddress: {}, deliveryAddress: {} },
@@ -12,7 +17,8 @@ const defaultProps = {
personalDetails: 'editable' as FieldsetVisibility,
billingAddress: 'editable' as FieldsetVisibility,
deliveryAddress: 'editable' as FieldsetVisibility
}
},
setComponentRef: setComponentRef
};
describe('OpenInvoice', () => {
@@ -77,7 +83,7 @@ describe('OpenInvoice', () => {
const payButton = jest.fn();
const wrapper = getWrapper({ showPayButton: true, payButton });
const status = 'loading';
wrapper.instance().setStatus(status);
componentRef.setStatus(status);
wrapper.update();
expect(payButton).toHaveBeenCalledWith(expect.objectContaining({ status }));
});

View File

@@ -1,5 +1,5 @@
import { h, createRef } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { h } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import useCoreContext from '../../../core/Context/useCoreContext';
import CompanyDetails from '../CompanyDetails';
import PersonalDetails from '../PersonalDetails';
@@ -17,16 +17,30 @@ import {
} from './types';
import './OpenInvoice.scss';
import IbanInput from '../IbanInput';
import { ComponentMethodsRef } from '../../types';
export default function OpenInvoice(props: OpenInvoiceProps) {
const { countryCode, visibility } = props;
const { i18n } = useCoreContext();
/** An object by which to expose 'public' members to the parent UIElement */
const openInvoiceRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(openInvoiceRef.current).length) {
props.setComponentRef?.(openInvoiceRef.current);
}
const initialActiveFieldsets: OpenInvoiceActiveFieldsets = getInitialActiveFieldsets(visibility, props.data);
const [activeFieldsets, setActiveFieldsets] = useState<OpenInvoiceActiveFieldsets>(initialActiveFieldsets);
const fieldsetsRefs: OpenInvoiceFieldsetsRefs = fieldsetsSchema.reduce((acc, fieldset) => {
acc[fieldset] = createRef();
return acc;
}, {});
const { current: fieldsetsRefs } = useRef<OpenInvoiceFieldsetsRefs>(
fieldsetsSchema.reduce((acc, fieldset) => {
acc[fieldset] = ref => {
fieldsetsRefs[fieldset].current = ref;
};
return acc;
}, {})
);
const checkFieldsets = () => Object.keys(activeFieldsets).every(fieldset => !activeFieldsets[fieldset] || !!valid[fieldset]);
const hasConsentCheckbox = !!props.consentCheckboxLabel;
@@ -41,7 +55,18 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
const [valid, setValid] = useState<OpenInvoiceStateValid>({});
const [status, setStatus] = useState('ready');
this.setStatus = setStatus;
// Expose methods expected by parent
openInvoiceRef.current.showValidation = () => {
fieldsetsSchema.forEach(fieldset => {
if (fieldsetsRefs[fieldset].current) fieldsetsRefs[fieldset].current.showValidation();
});
setErrors({
...(hasConsentCheckbox && { consentCheckbox: !data.consentCheckbox })
});
};
openInvoiceRef.current.setStatus = setStatus;
useEffect(() => {
const fieldsetsAreValid: boolean = checkFieldsets();
@@ -72,16 +97,6 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
setErrors(prevErrors => ({ ...prevErrors, consentCheckbox: !checked }));
};
this.showValidation = () => {
fieldsetsSchema.forEach(fieldset => {
if (fieldsetsRefs[fieldset].current) fieldsetsRefs[fieldset].current.showValidation();
});
setErrors({
...(hasConsentCheckbox && { consentCheckbox: !data.consentCheckbox })
});
};
return (
<div className="adyen-checkout__open-invoice">
{activeFieldsets.companyDetails && (
@@ -89,7 +104,7 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
data={props.data.companyDetails}
label="companyDetails"
onChange={handleFieldset('companyDetails')}
ref={fieldsetsRefs.companyDetails}
setComponentRef={fieldsetsRefs.companyDetails}
visibility={visibility.companyDetails}
/>
)}
@@ -100,7 +115,7 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
requiredFields={props.personalDetailsRequiredFields}
label="personalDetails"
onChange={handleFieldset('personalDetails')}
ref={fieldsetsRefs.personalDetails}
setComponentRef={fieldsetsRefs.personalDetails}
visibility={visibility.personalDetails}
/>
)}
@@ -124,7 +139,7 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
data={data.billingAddress}
label="billingAddress"
onChange={handleFieldset('billingAddress')}
ref={fieldsetsRefs.billingAddress}
setComponentRef={fieldsetsRefs.billingAddress}
visibility={visibility.billingAddress}
/>
)}
@@ -146,7 +161,7 @@ export default function OpenInvoice(props: OpenInvoiceProps) {
data={data.deliveryAddress}
label="deliveryAddress"
onChange={handleFieldset('deliveryAddress')}
ref={fieldsetsRefs.deliveryAddress}
setComponentRef={fieldsetsRefs.deliveryAddress}
visibility={visibility.deliveryAddress}
/>
)}

View File

@@ -1,8 +1,8 @@
import { AddressData, FieldsetVisibility, PersonalDetailsSchema } from '../../../types';
import { CompanyDetailsSchema } from '../CompanyDetails/types';
import { AddressSpecifications } from '../Address/types';
import {UIElementProps} from "../../types";
import UIElement from "../../UIElement";
import { UIElementProps } from '../../types';
import UIElement from '../../UIElement';
export interface OpenInvoiceVisibility {
companyDetails?: FieldsetVisibility;
@@ -13,12 +13,12 @@ export interface OpenInvoiceVisibility {
}
export interface BankDetailsSchema {
countryCode?: string,
ibanNumber?: any,
ownerName?: string
countryCode?: string;
ibanNumber?: any;
ownerName?: string;
}
export interface OpenInvoiceProps extends UIElementProps{
export interface OpenInvoiceProps extends UIElementProps {
allowedCountries?: string[];
consentCheckboxLabel: any;
countryCode?: string;
@@ -27,7 +27,7 @@ export interface OpenInvoiceProps extends UIElementProps{
personalDetails?: PersonalDetailsSchema;
billingAddress?: AddressData;
deliveryAddress?: AddressData;
bankAccount?: BankDetailsSchema
bankAccount?: BankDetailsSchema;
};
onChange: (state: any, element?: UIElement) => void;
payButton: any;
@@ -36,6 +36,7 @@ export interface OpenInvoiceProps extends UIElementProps{
personalDetailsRequiredFields?: string[];
billingAddressRequiredFields?: string[];
billingAddressSpecification?: AddressSpecifications;
setComponentRef?: (ref) => void;
}
export interface OpenInvoiceStateData {
@@ -43,7 +44,7 @@ export interface OpenInvoiceStateData {
personalDetails?: PersonalDetailsSchema;
billingAddress?: AddressData;
deliveryAddress?: AddressData;
bankAccount?: BankDetailsSchema
bankAccount?: BankDetailsSchema;
consentCheckbox?: boolean;
}

View File

@@ -1,5 +1,5 @@
import { h } from 'preact';
import { useEffect, useMemo } from 'preact/hooks';
import { Fragment, h } from 'preact';
import { useEffect, useMemo, useRef } from 'preact/hooks';
import Fieldset from '../FormFields/Fieldset';
import Field from '../FormFields/Field';
import ReadOnlyPersonalDetails from './ReadOnlyPersonalDetails';
@@ -12,6 +12,7 @@ import { PersonalDetailsSchema } from '../../../types';
import { getFormattedData } from './utils';
import useForm from '../../../utils/useForm';
import './PersonalDetails.scss';
import { ComponentMethodsRef } from '../../types';
const personalDetailsSchema = ['firstName', 'lastName', 'gender', 'dateOfBirth', 'shopperEmail', 'telephoneNumber'];
@@ -19,6 +20,14 @@ export default function PersonalDetails(props: PersonalDetailsProps) {
const { label = '', namePrefix, placeholders, requiredFields, visibility } = props;
const { i18n } = useCoreContext();
/** An object by which to expose 'public' members to the parent UIElement */
const personalDetailsRef = useRef<ComponentMethodsRef>({});
// Just call once
if (!Object.keys(personalDetailsRef.current).length) {
props.setComponentRef?.(personalDetailsRef.current);
}
const isDateInputSupported = useMemo(checkDateInputSupport, []);
const { handleChangeFor, triggerValidation, data, valid, errors, isValid } = useForm<PersonalDetailsSchema>({
schema: requiredFields,
@@ -27,13 +36,20 @@ export default function PersonalDetails(props: PersonalDetailsProps) {
defaultData: props.data
});
const eventHandler = (mode: string): Function => (e: Event): void => {
const { name } = e.target as HTMLInputElement;
const key = name.split(`${namePrefix}.`).pop();
handleChangeFor(key, mode)(e);
// Expose method expected by (parent) PersonalDetails.tsx
personalDetailsRef.current.showValidation = () => {
triggerValidation();
};
const eventHandler =
(mode: string): Function =>
(e: Event): void => {
const { name } = e.target as HTMLInputElement;
const key = name.split(`${namePrefix}.`).pop();
handleChangeFor(key, mode)(e);
};
const generateFieldName = (name: string): string => `${namePrefix ? `${namePrefix}.` : ''}${name}`;
const getErrorMessage = error => (error && error.errorMessage ? i18n.get(error.errorMessage) : !!error);
@@ -42,136 +58,138 @@ export default function PersonalDetails(props: PersonalDetailsProps) {
props.onChange({ data: formattedData, valid, errors, isValid });
}, [data, valid, errors, isValid]);
this.showValidation = triggerValidation;
if (visibility === 'hidden') return null;
if (visibility === 'readOnly') return <ReadOnlyPersonalDetails {...props} data={data} />;
return (
<Fieldset classNameModifiers={['personalDetails']} label={label}>
{requiredFields.includes('firstName') && (
<Field
label={i18n.get('firstName')}
classNameModifiers={['col-50', 'firstName']}
errorMessage={getErrorMessage(errors.firstName)}
name={'firstName'}
i18n={i18n}
>
{renderFormField('text', {
name: generateFieldName('firstName'),
value: data.firstName,
classNameModifiers: ['firstName'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.firstName,
spellCheck: false,
required: true
})}
</Field>
)}
<Fragment>
<Fieldset classNameModifiers={['personalDetails']} label={label}>
{requiredFields.includes('firstName') && (
<Field
label={i18n.get('firstName')}
classNameModifiers={['col-50', 'firstName']}
errorMessage={getErrorMessage(errors.firstName)}
name={'firstName'}
i18n={i18n}
>
{renderFormField('text', {
name: generateFieldName('firstName'),
value: data.firstName,
classNameModifiers: ['firstName'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.firstName,
spellCheck: false,
required: true
})}
</Field>
)}
{requiredFields.includes('lastName') && (
<Field
label={i18n.get('lastName')}
classNameModifiers={['col-50', 'lastName']}
errorMessage={getErrorMessage(errors.lastName)}
name={'lastName'}
i18n={i18n}
>
{renderFormField('text', {
name: generateFieldName('lastName'),
value: data.lastName,
classNameModifiers: ['lastName'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.lastName,
spellCheck: false,
required: true
})}
</Field>
)}
{requiredFields.includes('lastName') && (
<Field
label={i18n.get('lastName')}
classNameModifiers={['col-50', 'lastName']}
errorMessage={getErrorMessage(errors.lastName)}
name={'lastName'}
i18n={i18n}
>
{renderFormField('text', {
name: generateFieldName('lastName'),
value: data.lastName,
classNameModifiers: ['lastName'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.lastName,
spellCheck: false,
required: true
})}
</Field>
)}
{requiredFields.includes('gender') && (
<Field errorMessage={!!errors.gender} classNameModifiers={['gender']} name={'gender'} useLabelElement={false}>
{renderFormField('radio', {
i18n,
name: generateFieldName('gender'),
value: data.gender,
items: [
{ id: 'MALE', name: 'male' },
{ id: 'FEMALE', name: 'female' }
],
classNameModifiers: ['gender'],
onInput: eventHandler('input'),
onChange: eventHandler('blur'),
required: true
})}
</Field>
)}
{requiredFields.includes('gender') && (
<Field errorMessage={!!errors.gender} classNameModifiers={['gender']} name={'gender'} useLabelElement={false}>
{renderFormField('radio', {
i18n,
name: generateFieldName('gender'),
value: data.gender,
items: [
{ id: 'MALE', name: 'male' },
{ id: 'FEMALE', name: 'female' }
],
classNameModifiers: ['gender'],
onInput: eventHandler('input'),
onChange: eventHandler('blur'),
required: true
})}
</Field>
)}
{requiredFields.includes('dateOfBirth') && (
<Field
label={i18n.get('dateOfBirth')}
classNameModifiers={['col-50', 'lastName']}
errorMessage={getErrorMessage(errors.dateOfBirth)}
helper={isDateInputSupported ? null : i18n.get('dateOfBirth.format')}
name={'dateOfBirth'}
i18n={i18n}
>
{renderFormField('date', {
name: generateFieldName('dateOfBirth'),
value: data.dateOfBirth,
classNameModifiers: ['dateOfBirth'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.dateOfBirth,
required: true
})}
</Field>
)}
{requiredFields.includes('dateOfBirth') && (
<Field
label={i18n.get('dateOfBirth')}
classNameModifiers={['col-50', 'lastName']}
errorMessage={getErrorMessage(errors.dateOfBirth)}
helper={isDateInputSupported ? null : i18n.get('dateOfBirth.format')}
name={'dateOfBirth'}
i18n={i18n}
>
{renderFormField('date', {
name: generateFieldName('dateOfBirth'),
value: data.dateOfBirth,
classNameModifiers: ['dateOfBirth'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.dateOfBirth,
required: true
})}
</Field>
)}
{requiredFields.includes('shopperEmail') && (
<Field
label={i18n.get('shopperEmail')}
classNameModifiers={['shopperEmail']}
errorMessage={getErrorMessage(errors.shopperEmail)}
dir={'ltr'}
name={'emailAddress'}
i18n={i18n}
>
{renderFormField('emailAddress', {
name: generateFieldName('shopperEmail'),
value: data.shopperEmail,
classNameModifiers: ['shopperEmail'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.shopperEmail,
required: true
})}
</Field>
)}
{requiredFields.includes('shopperEmail') && (
<Field
label={i18n.get('shopperEmail')}
classNameModifiers={['shopperEmail']}
errorMessage={getErrorMessage(errors.shopperEmail)}
dir={'ltr'}
name={'emailAddress'}
i18n={i18n}
>
{renderFormField('emailAddress', {
name: generateFieldName('shopperEmail'),
value: data.shopperEmail,
classNameModifiers: ['shopperEmail'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.shopperEmail,
required: true
})}
</Field>
)}
{requiredFields.includes('telephoneNumber') && (
<Field
label={i18n.get('telephoneNumber')}
classNameModifiers={['telephoneNumber']}
errorMessage={getErrorMessage(errors.telephoneNumber)}
dir={'ltr'}
name={'telephoneNumber'}
i18n={i18n}
>
{renderFormField('tel', {
name: generateFieldName('telephoneNumber'),
value: data.telephoneNumber,
classNameModifiers: ['telephoneNumber'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.telephoneNumber,
required: true
})}
</Field>
)}
</Fieldset>
{requiredFields.includes('telephoneNumber') && (
<Field
label={i18n.get('telephoneNumber')}
classNameModifiers={['telephoneNumber']}
errorMessage={getErrorMessage(errors.telephoneNumber)}
dir={'ltr'}
name={'telephoneNumber'}
i18n={i18n}
>
{renderFormField('tel', {
name: generateFieldName('telephoneNumber'),
value: data.telephoneNumber,
classNameModifiers: ['telephoneNumber'],
onInput: eventHandler('input'),
onBlur: eventHandler('blur'),
placeholder: placeholders.telephoneNumber,
required: true
})}
</Field>
)}
</Fieldset>
{/* Needed to easily test when showValidation is called */}
{process.env.NODE_ENV !== 'production' && props.showPayButton && props.payButton({ label: i18n.get('continue') })}
</Fragment>
);
}

View File

@@ -14,6 +14,9 @@ export interface PersonalDetailsProps {
readonly?: boolean;
ref?: any;
validationRules?: ValidatorRules;
setComponentRef?: (ref) => void;
payButton?: (obj) => {};
showPayButton?: boolean;
}
export interface PersonalDetailsStateError {

View File

@@ -131,3 +131,9 @@ export interface UIElementProps extends BaseElementProps {
/** @internal */
i18n?: Language;
}
// An interface for the members exposed by a component to its parent UIElement
export interface ComponentMethodsRef {
showValidation?: () => void;
setStatus?(status: UIElementStatus): void;
}