From 35bb12c7020c721401cb2d7bf0f8dcdbb5350f7f Mon Sep 17 00:00:00 2001 From: "e.kireev" Date: Tue, 16 Apr 2024 16:44:45 +0300 Subject: [PATCH] feat(PAYMENTS-18930): form loaded event --- src/core/actions/show-qr-code.action.type.ts | 2 + src/core/form/fields-type.enum.ts | 5 ++ src/core/form/form-loader.ts | 66 +++++++++++++++++++ .../web-components/web-component.abstract.ts | 16 ++++- .../headless-checkout/headless-checkout.ts | 4 ++ .../apple-pay/apple-pay.component.ts | 2 - .../checkbox/checkbox.component.spec.ts | 10 ++- .../checkbox/checkbox.component.ts | 2 +- .../payment-form.component.spec.ts | 10 ++- .../payment-form/payment-form.component.ts | 11 +++- .../payment-methods.component.ts | 3 - .../qr-code/qr-code.component.ts | 24 ++++--- .../saved-methods/saved-methods.component.ts | 2 - .../select/select.component.spec.ts | 10 +++ .../web-components/select/select.component.ts | 2 +- .../text-component/text.component.spec.ts | 47 ++----------- .../text-component/text.component.ts | 3 +- 17 files changed, 151 insertions(+), 68 deletions(-) create mode 100644 src/core/form/fields-type.enum.ts create mode 100644 src/core/form/form-loader.ts diff --git a/src/core/actions/show-qr-code.action.type.ts b/src/core/actions/show-qr-code.action.type.ts index 1697d79..2a69168 100644 --- a/src/core/actions/show-qr-code.action.type.ts +++ b/src/core/actions/show-qr-code.action.type.ts @@ -1,8 +1,10 @@ import { Action } from './action.interface'; +import { Field } from '../form/field.interface'; export type ShowQrCodeActionType = 'show_qr_code'; export interface ShowQrCodeActionData { + fields: Field[]; submitButtonText: string; } diff --git a/src/core/form/fields-type.enum.ts b/src/core/form/fields-type.enum.ts new file mode 100644 index 0000000..6b00994 --- /dev/null +++ b/src/core/form/fields-type.enum.ts @@ -0,0 +1,5 @@ +export enum FieldsType { + text = 'text', + check = 'check', + select = 'select', +} diff --git a/src/core/form/form-loader.ts b/src/core/form/form-loader.ts new file mode 100644 index 0000000..71ee312 --- /dev/null +++ b/src/core/form/form-loader.ts @@ -0,0 +1,66 @@ +import { singleton } from 'tsyringe'; +import { Field } from './field.interface'; +import { FieldsType } from './fields-type.enum'; + +@singleton() +export class FormLoader { + private _fields!: { [key: string]: boolean }; + private _isAllFieldsLoaded!: Promise; + + private _resolve!: () => void; + private _isPromiseResolved = false; + + public async setupAndAwaitFieldsLoading(fields: Field[]): Promise { + this._isAllFieldsLoaded = new Promise((resolve) => { + this._resolve = () => { + resolve(); + }; + }); + + this._isPromiseResolved = false; + this._fields = {}; + + const filteredFields = this.filterFields(fields); + + const isFormWithoutFields = !filteredFields.length; + + if (isFormWithoutFields) { + this._resolve(); + this._isPromiseResolved = true; + } + + filteredFields.forEach((field) => { + this._fields[field.name] = false; + }); + + return this._isAllFieldsLoaded; + } + + public setFieldLoaded(name: string): void { + if (name in this._fields) { + this._fields[name] = true; + } + + if (this.isAllFieldsLoaded && !this._isPromiseResolved) { + this._resolve(); + this._isPromiseResolved = true; + } + } + + private get isAllFieldsLoaded(): boolean { + return Object.values(this._fields).every((value) => value); + } + + private filterFields(fields: Field[]): Field[] { + return fields.filter((field) => { + const isTextControl = field.type === FieldsType.text; + const isCheckboxControl = field.type === FieldsType.check; + const isSelectControl = field.type === FieldsType.select; + const isQrCodeControl = field.name === 'qr'; + + return ( + isTextControl || isCheckboxControl || isSelectControl || isQrCodeControl + ); + }); + } +} diff --git a/src/core/web-components/web-component.abstract.ts b/src/core/web-components/web-component.abstract.ts index 22c5acd..b8cbf46 100644 --- a/src/core/web-components/web-component.abstract.ts +++ b/src/core/web-components/web-component.abstract.ts @@ -1,14 +1,23 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { isLoadingCssClassName } from '../../shared/loading-state/is-loading-css-class-name.const'; +import { FormLoader } from '../form/form-loader'; +import { container } from 'tsyringe'; import { createFinishLoadingEvent } from '../../shared/loading-state/dispatch-finish-loading-event.function'; +import { isLoadingCssClassName } from '../../shared/loading-state/is-loading-css-class-name.const'; export abstract class WebComponentAbstract extends HTMLElement { + protected formLoader!: FormLoader; + protected eventListeners: Array<{ element: Element; eventType: string; listener(event: Event): void; }> = []; + public constructor() { + super(); + this.formLoader = container.resolve(FormLoader); + } + protected abstract getHtml(): string; protected connectedCallback(): void { @@ -75,6 +84,10 @@ export abstract class WebComponentAbstract extends HTMLElement { } } + protected finishLoadingFormControlHandler(componentName: string): void { + this.formLoader.setFieldLoaded(componentName); + } + protected startLoadingComponentHandler(): void { this.classList.add(isLoadingCssClassName); } @@ -86,5 +99,6 @@ export abstract class WebComponentAbstract extends HTMLElement { protected dispatchFinishLoadingEvent(componentName: string): void { this.dispatchEvent(createFinishLoadingEvent(componentName)); + this.formLoader.setFieldLoaded(componentName); } } diff --git a/src/features/headless-checkout/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index f1369c4..1d56eb2 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -38,6 +38,7 @@ import { Themes } from '../../core/customization/themes.type'; import { Lang } from '../../core/i18n/lang.enum'; import { getCountryListHandler } from './post-messages-handlers/get-country-list.handler'; import { CountryResponse } from '../../core/country-response.interface'; +import { FormLoader } from '../../core/form/form-loader'; @singleton() export class HeadlessCheckout { @@ -137,6 +138,8 @@ export class HeadlessCheckout { activate: (): void => { this.formStatus = FormStatus.active; }, + setupAndAwaitFieldsLoading: async (fields: Field[]): Promise => + this.formLoader.setupAndAwaitFieldsLoading(fields), }; public get formConfiguration(): FormConfiguration | undefined { @@ -159,6 +162,7 @@ export class HeadlessCheckout { private readonly headlessCheckoutSpy: HeadlessCheckoutSpy, private readonly formSpy: FormSpy, private readonly themesLoader: ThemesLoader, + private readonly formLoader: FormLoader, ) {} public async init(environment: { diff --git a/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts b/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts index b5084f6..3e4a614 100644 --- a/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts +++ b/src/features/headless-checkout/web-components/apple-pay/apple-pay.component.ts @@ -31,7 +31,6 @@ export class ApplePayComponent extends SecureComponentAbstract { private readonly listenApplePayWindowCloseDelay = 100; private applePayWindow?: Window | null; private listenApplePayWindowCloseTimeout?: ReturnType; - private isWaitingPayment = false; public constructor() { super(); @@ -111,7 +110,6 @@ export class ApplePayComponent extends SecureComponentAbstract { } private setupWaitingPayment(isWaiting: boolean): void { - this.isWaitingPayment = isWaiting; if (isWaiting) { this.drawWaitingElement(); this.hidePayButton(); diff --git a/src/features/headless-checkout/web-components/checkbox/checkbox.component.spec.ts b/src/features/headless-checkout/web-components/checkbox/checkbox.component.spec.ts index 24d1cc5..4876e57 100644 --- a/src/features/headless-checkout/web-components/checkbox/checkbox.component.spec.ts +++ b/src/features/headless-checkout/web-components/checkbox/checkbox.component.spec.ts @@ -7,6 +7,7 @@ import { CheckboxComponent } from './checkbox.component'; import { CheckboxComponentConfig } from './checkbox-component-config.interface'; import { XpsBoolean } from '../../../../core/xps-boolean.enum'; import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { FormLoader } from '../../../../core/form/form-loader'; const config: CheckboxComponentConfig = { name: 'test', @@ -29,6 +30,7 @@ describe('CheckboxComponent', () => { let headlessCheckout: HeadlessCheckout; let postMessagesClient: PostMessagesClient; let windowService: Window; + let formLoader: FormLoader; window.customElements.define( WebComponentTagName.CheckboxComponent, @@ -48,6 +50,11 @@ describe('CheckboxComponent', () => { windowService = window; + formLoader = { + setupAndAwaitFieldsLoading: noopStub, + setFieldLoaded: noopStub, + } as unknown as FormLoader; + container.clearInstances(); container @@ -64,7 +71,8 @@ describe('CheckboxComponent', () => { }, } as FormSpy, }) - .register(Window, { useValue: windowService }); + .register(Window, { useValue: windowService }) + .register(FormLoader, { useValue: formLoader }); }); afterEach(() => { diff --git a/src/features/headless-checkout/web-components/checkbox/checkbox.component.ts b/src/features/headless-checkout/web-components/checkbox/checkbox.component.ts index 9c9a2ca..bc900b7 100644 --- a/src/features/headless-checkout/web-components/checkbox/checkbox.component.ts +++ b/src/features/headless-checkout/web-components/checkbox/checkbox.component.ts @@ -40,11 +40,11 @@ export class CheckboxComponent extends BaseControl { void this.getComponentConfig(this.controlName).then((config) => { this.config = config as CheckboxComponentConfig; - this.render(); this.addEventListenerToElement(this.inputRef, 'change', (event: Event) => this.notifyOnValueChanges(event), ); + this.finishLoadingFormControlHandler(this.controlName); }); } diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts b/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts index 441d4de..28e0657 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts @@ -8,6 +8,7 @@ import { PaymentFormComponent } from './payment-form.component'; import { Field } from '../../../../core/form/field.interface'; import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client'; import { FieldSettings } from './field-settings.interface'; +import { FormLoader } from '../../../../core/form/form-loader'; function createComponent(): void { const element = document.createElement( @@ -47,6 +48,7 @@ describe('PaymentFormComponent', () => { let paymentFormFieldsManager: PaymentFormFieldsService; let postMessagesClient: PostMessagesClient; let windowService: Window; + let formLoader: FormLoader; window.customElements.define( WebComponentTagName.PaymentFormComponent, @@ -77,6 +79,11 @@ describe('PaymentFormComponent', () => { }, } as unknown as FormSpy; + formLoader = { + setupAndAwaitFieldsLoading: noopStub, + setFieldLoaded: noopStub, + } as unknown as FormLoader; + paymentFormFieldsManager = { createMissedFields: noopStub, removeExtraFields: noopStub, @@ -104,7 +111,8 @@ describe('PaymentFormComponent', () => { .register(PostMessagesClient, { useValue: postMessagesClient, }) - .register(Window, { useValue: windowService }); + .register(Window, { useValue: windowService }) + .register(FormLoader, { useValue: formLoader }); }); afterEach(() => { diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts b/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts index 2bfc3c5..0db94c2 100644 --- a/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts @@ -36,12 +36,15 @@ export class PaymentFormComponent extends WebComponentAbstract { return; } const formExpectedFields = this.formSpy.formFields; - const formRequriedFields = this.getRequriedFields(this.formSpy.formFields); + + const formRequiredFields = this.getRequriedFields(this.formSpy.formFields); super.render(); if (formExpectedFields) { + this.setupFormLoader(formRequiredFields); + const expectedFields = this.getFieldsSettings(formExpectedFields); - const requiredFields = this.getFieldsSettings(formRequriedFields); + const requiredFields = this.getFieldsSettings(formRequiredFields); const existsControls = this.getExistsControls(); this.setupFormFields(expectedFields, requiredFields, existsControls); @@ -150,4 +153,8 @@ export class PaymentFormComponent extends WebComponentAbstract { () => null, ); } + + private setupFormLoader(fields: Field[]): void { + void this.formLoader.setupAndAwaitFieldsLoading(fields); + } } diff --git a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts index c7cacea..dc7796d 100644 --- a/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts +++ b/src/features/headless-checkout/web-components/payment-methods/payment-methods.component.ts @@ -48,8 +48,6 @@ export class PaymentMethodsComponent extends WebComponentAbstract { } protected connectedCallback(): void { - this.startLoadingComponentHandler(); - if (!this.headlessCheckoutSpy.appWasInit) { this.headlessCheckoutSpy.listenAppInit(() => this.connectedCallback()); return; @@ -103,7 +101,6 @@ export class PaymentMethodsComponent extends WebComponentAbstract { this.filteredMethods = this.paymentMethods.slice(); super.render(); - this.finishLoadingComponentHandler('payment-methods'); this.listenClicks(); this.setupSearch(); }; diff --git a/src/features/headless-checkout/web-components/qr-code/qr-code.component.ts b/src/features/headless-checkout/web-components/qr-code/qr-code.component.ts index f9147ef..abfde2a 100644 --- a/src/features/headless-checkout/web-components/qr-code/qr-code.component.ts +++ b/src/features/headless-checkout/web-components/qr-code/qr-code.component.ts @@ -1,12 +1,13 @@ import { SecureComponentAbstract } from '../../../../core/web-components/secure-component/secure-component.abstract'; -import { EventName } from '../../../../core/event-name.enum'; -import { finishLoadComponentHandler } from '../../post-messages-handlers/finish-load-component.handler'; import { container } from 'tsyringe'; import { HeadlessCheckout } from '../../headless-checkout'; import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { EventName } from '../../../../core/event-name.enum'; +import { finishLoadComponentHandler } from '../../post-messages-handlers/finish-load-component.handler'; export class QrCodeComponent extends SecureComponentAbstract { protected componentName = 'qr-code'; + protected inputName = 'qr'; private readonly headlessCheckout: HeadlessCheckout; private readonly formSpy: FormSpy; @@ -14,21 +15,24 @@ export class QrCodeComponent extends SecureComponentAbstract { super(); this.headlessCheckout = container.resolve(HeadlessCheckout); this.formSpy = container.resolve(FormSpy); + + this.headlessCheckout.events.onCoreEvent( + EventName.finishLoadComponent, + finishLoadComponentHandler, + (res) => { + if (res?.fieldName && res?.fieldName === this.inputName) { + this.finishLoadingFormControlHandler(this.inputName); + } + }, + ); } protected connectedCallback(): void { - this.startLoadingComponentHandler(); - if (!this.formSpy.formWasInit) { this.formSpy.listenFormInit(() => this.connectedCallback()); + return; } - this.headlessCheckout.events.onCoreEvent( - EventName.finishLoadComponent, - finishLoadComponentHandler, - () => this.finishLoadingComponentHandler('qr-code'), - ); - super.connectedCallback(); } diff --git a/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts index 1985b27..e749034 100644 --- a/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts +++ b/src/features/headless-checkout/web-components/saved-methods/saved-methods.component.ts @@ -70,7 +70,6 @@ export class SavedMethodsComponent extends WebComponentAbstract { } protected connectedCallback(): void { - this.startLoadingComponentHandler(); if (!this.headlessCheckoutSpy.appWasInit) { this.headlessCheckoutSpy.listenAppInit(() => this.connectedCallback()); return; @@ -98,7 +97,6 @@ export class SavedMethodsComponent extends WebComponentAbstract { ): void => { this.savedMethods = savedMethods; super.render(); - this.finishLoadingComponentHandler('saved-methods'); this.listenClicks(); }; diff --git a/src/features/headless-checkout/web-components/select/select.component.spec.ts b/src/features/headless-checkout/web-components/select/select.component.spec.ts index 421824f..27898c7 100644 --- a/src/features/headless-checkout/web-components/select/select.component.spec.ts +++ b/src/features/headless-checkout/web-components/select/select.component.spec.ts @@ -7,6 +7,7 @@ import { SelectAttributes } from './select-attributes.enum'; import { SelectComponent } from './select.component'; import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy'; import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { FormLoader } from '../../../../core/form/form-loader'; function createComponent(name: string): HTMLElement { const element = document.createElement(WebComponentTagName.SelectComponent); @@ -37,6 +38,7 @@ describe('SelectComponent', () => { let postMessagesClient: PostMessagesClient; let headlessCheckout: HeadlessCheckout; let headlessCheckoutSpy: HeadlessCheckoutSpy; + let formLoader: FormLoader; window.customElements.define( WebComponentTagName.SelectComponent, @@ -50,6 +52,11 @@ describe('SelectComponent', () => { send: noopStub, } as unknown as PostMessagesClient; + formLoader = { + setupAndAwaitFieldsLoading: noopStub, + setFieldLoaded: noopStub, + } as unknown as FormLoader; + headlessCheckout = { form: { onFieldsStatusChange: noopStub, @@ -84,6 +91,9 @@ describe('SelectComponent', () => { }) .register(HeadlessCheckoutSpy, { useValue: headlessCheckoutSpy, + }) + .register(FormLoader, { + useValue: formLoader, }); }); diff --git a/src/features/headless-checkout/web-components/select/select.component.ts b/src/features/headless-checkout/web-components/select/select.component.ts index adee00a..18ea3a2 100644 --- a/src/features/headless-checkout/web-components/select/select.component.ts +++ b/src/features/headless-checkout/web-components/select/select.component.ts @@ -421,8 +421,8 @@ export class SelectComponent extends BaseControl { void this.getComponentConfig(this.controlName).then((config) => { this.config = config; - this.render(); + this.finishLoadingFormControlHandler(this.controlName); }); } } diff --git a/src/features/headless-checkout/web-components/text-component/text.component.spec.ts b/src/features/headless-checkout/web-components/text-component/text.component.spec.ts index 7667ace..a9c6e50 100644 --- a/src/features/headless-checkout/web-components/text-component/text.component.spec.ts +++ b/src/features/headless-checkout/web-components/text-component/text.component.spec.ts @@ -6,7 +6,6 @@ import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; import { HeadlessCheckout } from '../../headless-checkout'; import { TextComponent } from './text.component'; import { FormFieldsStatus } from '../../../../core/form/form-fields-status.interface'; -import { isLoadingCssClassName } from '../../../../shared/loading-state/is-loading-css-class-name.const'; const fieldName = 'zip'; @@ -61,7 +60,7 @@ describe('TextComponent', () => { window.customElements.define( WebComponentTagName.TextComponent, - TextComponent + TextComponent, ); beforeEach(() => { @@ -129,7 +128,7 @@ describe('TextComponent', () => { spyOn(headlessCheckout.form, 'onFieldsStatusChange').and.callFake( (callbackFn) => { setTimeout(() => (callback = callbackFn)); - } + }, ); element = createComponent(); @@ -151,7 +150,7 @@ describe('TextComponent', () => { spyOn(headlessCheckout.form, 'onFieldsStatusChange').and.callFake( (callbackFn) => { setTimeout(() => (callback = callbackFn)); - } + }, ); element = createComponent(); @@ -173,7 +172,7 @@ describe('TextComponent', () => { spyOn(headlessCheckout.form, 'onFieldsStatusChange').and.callFake( (callbackFn) => { setTimeout(() => (callback = callbackFn)); - } + }, ); element = createComponent(); @@ -195,7 +194,7 @@ describe('TextComponent', () => { spyOn(headlessCheckout.form, 'onFieldsStatusChange').and.callFake( (callbackFn) => { setTimeout(() => (callback = callbackFn)); - } + }, ); element = createComponent(); @@ -210,40 +209,4 @@ describe('TextComponent', () => { done(); }); }); - - it('Should add loading css class', () => { - spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); - spyOn(postMessagesClient, 'send').and.resolveTo({ - name: fieldName, - }); - - const element = createComponent(); - expect(element.classList).toContain(isLoadingCssClassName); - }); - - it('Should remove loading css class', (done) => { - // eslint-disable-next-line prefer-const - let element: HTMLElement; - let callback: (value?: unknown) => void = noopStub; - spyOn(headlessCheckout.events, 'onCoreEvent').and.callFake((...args) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - callback = args[2]; - - setTimeout(() => { - callback({ fieldName }); - expect(element?.classList).not.toContain(isLoadingCssClassName); - - done(); - }); - return noopStub; - }); - spyOnProperty(formSpy, 'formWasInit').and.returnValue(true); - spyOn(postMessagesClient, 'send').and.resolveTo({ - name: fieldName, - }); - - element = createComponent(); - expect(element.classList).toContain(isLoadingCssClassName); - }); }); diff --git a/src/features/headless-checkout/web-components/text-component/text.component.ts b/src/features/headless-checkout/web-components/text-component/text.component.ts index 9a64f07..b1d8885 100644 --- a/src/features/headless-checkout/web-components/text-component/text.component.ts +++ b/src/features/headless-checkout/web-components/text-component/text.component.ts @@ -36,7 +36,7 @@ export class TextComponent extends SecureComponentAbstract { finishLoadComponentHandler, (res) => { if (res?.fieldName && res?.fieldName === this.inputName) { - this.finishLoadingComponentHandler(this.inputName); + this.finishLoadingFormControlHandler(this.inputName); } }, ); @@ -47,7 +47,6 @@ export class TextComponent extends SecureComponentAbstract { } protected connectedCallback(): void { - this.startLoadingComponentHandler(); if (!this.formSpy.formWasInit) { this.formSpy.listenFormInit(() => this.getConfigFromInputName()); return;