diff --git a/package.json b/package.json index d046585..1840cd5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test:dev": "karma start --auto-watch", "lint": "eslint \"src/**/*.{ts,tsx}\"", "prepare": "husky install", - "lib-publish": "npm run build && npm test && npm run lint && npm publish --access public" + "lib-publish": "npm run build && npm test && npm run lint && npm publish --access public", + "prettier": "prettier --write ./src" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" diff --git a/src/core/event-name.enum.ts b/src/core/event-name.enum.ts index f79762c..8ff9ee1 100644 --- a/src/core/event-name.enum.ts +++ b/src/core/event-name.enum.ts @@ -13,4 +13,5 @@ export const enum EventName { legalComponentPong = 'legalComponentPong', financeDetails = 'financeDetails', nextAction = 'nextAction', + warning = 'warning', } diff --git a/src/core/post-messages-client/handler.type.ts b/src/core/post-messages-client/handler.type.ts index ac9e5e8..7c20087 100644 --- a/src/core/post-messages-client/handler.type.ts +++ b/src/core/post-messages-client/handler.type.ts @@ -2,5 +2,5 @@ import { Message } from '../message.interface'; export type Handler = ( data: Message, - callback?: () => void + callback?: (args?: unknown) => void ) => { isHandled: boolean; value?: T } | null; diff --git a/src/core/spy/form-spy/form-spy.spec.ts b/src/core/spy/form-spy/form-spy.spec.ts index 21d337b..2309506 100644 --- a/src/core/spy/form-spy/form-spy.spec.ts +++ b/src/core/spy/form-spy/form-spy.spec.ts @@ -29,13 +29,13 @@ describe('FormSpy', () => { expect(spy).toHaveBeenCalled(); }); - it('Should call formWasInitHandler once', () => { + it('Should call formWasInitHandler twice', () => { const observer = new Observer(); const spy = spyOn(observer, 'formInitHandler'); formSpy.listenFormInit(observer.formInitHandler); formSpy.formWasInit = true; formSpy.formWasInit = true; - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); }); it('Should call appWasInitHandler once', () => { diff --git a/src/core/spy/form-spy/form-spy.ts b/src/core/spy/form-spy/form-spy.ts index be2be54..7af2e0a 100644 --- a/src/core/spy/form-spy/form-spy.ts +++ b/src/core/spy/form-spy/form-spy.ts @@ -1,14 +1,13 @@ import { singleton } from 'tsyringe'; +import { Field } from '../../form/field.interface'; @singleton() export class FormSpy { private _formWasInit = false; + private _fields?: Field[]; private readonly _callbacks: Array<() => void> = []; public set formWasInit(value: boolean) { - if (this._formWasInit === value) { - return; - } this._formWasInit = value; if (value) { this.formWasInitHandler(); @@ -19,6 +18,14 @@ export class FormSpy { return this._formWasInit; } + public set formFields(fields: Field[] | undefined) { + this._fields = fields; + } + + public get formFields(): Field[] | undefined { + return this._fields; + } + public listenFormInit(callback: () => void): void { this._callbacks.push(callback); } diff --git a/src/core/web-components/web-component-tag-name.enum.ts b/src/core/web-components/web-component-tag-name.enum.ts index 11b1ffa..68c9c03 100644 --- a/src/core/web-components/web-component-tag-name.enum.ts +++ b/src/core/web-components/web-component-tag-name.enum.ts @@ -5,5 +5,6 @@ export enum WebComponentTagName { PriceTextComponent = 'psdk-price-text', FinanceDetailsComponent = 'psdk-finance-details', LegalComponent = 'psdk-legal', + PaymentFormComponent = 'psdk-payment-form', StatusComponent = 'psdk-status', } diff --git a/src/core/web-components/web-components.map.ts b/src/core/web-components/web-components.map.ts index 0956e57..66a6f29 100644 --- a/src/core/web-components/web-components.map.ts +++ b/src/core/web-components/web-components.map.ts @@ -6,6 +6,7 @@ import { StatusComponent } from '../../features/headless-checkout/web-components import { FinanceDetailsComponent } from '../../features/headless-checkout/web-components/finance-details/finance-details.component'; import { PriceTextComponent } from '../../features/headless-checkout/web-components/finance-details/price-text/price-text.component'; import { WebComponentTagName } from './web-component-tag-name.enum'; +import { PaymentFormComponent } from '../../features/headless-checkout/web-components/payment-form/payment-form.component'; export const webComponents: { [key in WebComponentTagName]: CustomElementConstructor; @@ -17,4 +18,5 @@ export const webComponents: { [WebComponentTagName.FinanceDetailsComponent]: FinanceDetailsComponent, [WebComponentTagName.LegalComponent]: LegalComponent, [WebComponentTagName.StatusComponent]: StatusComponent, + [WebComponentTagName.PaymentFormComponent]: PaymentFormComponent, }; diff --git a/src/features/headless-checkout/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index 61de9a5..3787733 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -22,6 +22,7 @@ import { getRegularMethodsHandler } from './post-messages-handlers/get-regular-m import { getSavedMethodsHandler } from './post-messages-handlers/get-saved-methods.handler'; import { getUserBalanceHandler } from './post-messages-handlers/get-user-balance.handler'; import { nextActionHandler } from './post-messages-handlers/next-action.handler'; +import { Field } from '../../core/form/field.interface'; import { getPaymentStatusHandler } from './post-messages-handlers/get-payment-status/get-payment-status.handler'; import { headlessCheckoutAppUrl } from './environment'; import { FinanceDetails } from '../../core/finance-details/finance-details.interface'; @@ -71,7 +72,12 @@ export class HeadlessCheckout { }; return this.postMessagesClient.send
(msg, (message) => - initFormHandler(message, () => (this.formSpy.formWasInit = true)) + initFormHandler(message, (args?: unknown) => { + if (args) { + this.formSpy.formFields = (args as { fields: Field[] }).fields; + } + this.formSpy.formWasInit = true; + }) ) as Promise; }, diff --git a/src/features/headless-checkout/post-messages-handlers/init-form.handler.ts b/src/features/headless-checkout/post-messages-handlers/init-form.handler.ts index f3d237b..ed18a90 100644 --- a/src/features/headless-checkout/post-messages-handlers/init-form.handler.ts +++ b/src/features/headless-checkout/post-messages-handlers/init-form.handler.ts @@ -5,11 +5,11 @@ import { Handler } from '../../../core/post-messages-client/handler.type'; export const initFormHandler: Handler = ( message: Message, - callback?: () => void + callback?: (args?: unknown) => void ): { isHandled: boolean; value?: Form } | null => { if (isInitFormEventMessage(message)) { if (typeof callback === 'function') { - callback(); + callback(message.data); } return { isHandled: true, diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts new file mode 100644 index 0000000..e08d687 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.spec.ts @@ -0,0 +1,22 @@ +import { getInvalidFieldsNames } from './get-invalid-fields-names.function'; + +const expectedFieldsNames = ['zip', 'email']; + +describe('getInvalidFieldsNames', () => { + it('Should return invalid names', () => { + const exists = ['zip', 'card']; + expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual([ + 'card', + ]); + }); + + it('Should return invalid names if duplicates', () => { + const exists = ['zip', 'zip']; + expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual(['zip']); + }); + + it('Should not filter empty name fields', () => { + const exists = ['zip', '']; + expect(getInvalidFieldsNames(expectedFieldsNames, exists)).toEqual([]); + }); +}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts new file mode 100644 index 0000000..520eb27 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-invalid-fields-names.function.ts @@ -0,0 +1,32 @@ +export function getInvalidFieldsNames( + expectedFieldsNames: string[], + existsControlsNames: Array +): string[] { + const fieldsExistsCountMap: { [k: string]: number } = {}; + for (const name of existsControlsNames) { + if (name) { + const count = fieldsExistsCountMap[name]; + if (!count) { + fieldsExistsCountMap[name] = 1; + } else { + fieldsExistsCountMap[name] = count + 1; + } + } + } + + const invalidFieldNames = []; + + // eslint-disable-next-line prefer-const + for (let [name, count] of Object.entries(fieldsExistsCountMap)) { + if (!expectedFieldsNames.some((value) => value === name)) { + invalidFieldNames.push(name); + } else if (count > 1) { + while (count > 1) { + invalidFieldNames.push(name); + count--; + } + } + } + + return invalidFieldNames; +} diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts new file mode 100644 index 0000000..b64206d --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.spec.ts @@ -0,0 +1,12 @@ +import { getMissedFieldsNames } from './get-missed-fields-names.function'; + +const expectedFieldsNames = ['zip', 'email']; + +describe('getMissedFieldsNames', () => { + it('Should return missed names', () => { + const exists = ['zip', 'card', '']; + expect(getMissedFieldsNames(expectedFieldsNames, exists)).toEqual([ + 'email', + ]); + }); +}); diff --git a/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts new file mode 100644 index 0000000..0a201e4 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/get-missed-fields-names.function.ts @@ -0,0 +1,20 @@ +export function getMissedFieldsNames( + expectedFieldsNames: string[], + existsControlsNames: Array +): string[] { + const fieldsExistsMap: { [k: string]: boolean } = {}; + + for (const name of existsControlsNames) { + if (name) { + fieldsExistsMap[name] = true; + } + } + + const missedFieldsNames = []; + for (const name of expectedFieldsNames) { + if (!fieldsExistsMap[name]) { + missedFieldsNames.push(name); + } + } + return missedFieldsNames; +} diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts new file mode 100644 index 0000000..46e1634 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.spec.ts @@ -0,0 +1,75 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; +import { PaymentFormFieldsService } from './payment-form-fields.service'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; +import { noopStub } from '../../../../tests/stubs/noop.stub'; + +const getTextInputElements = (names: string[]): NodeListOf => { + const mockContainer = document.createElement('div'); + for (const name of names) { + const mockElement = document.createElement( + WebComponentTagName.TextComponent + ); + mockElement.setAttribute('name', name); + mockContainer.appendChild(mockElement); + } + + return mockContainer.querySelectorAll(WebComponentTagName.TextComponent); +}; + +describe('PaymentFormFieldsManager', () => { + let paymentFormFieldsManager: PaymentFormFieldsService; + let windowService: Window; + + beforeEach(() => { + windowService = window; + + container.register(Window, { useValue: windowService }); + paymentFormFieldsManager = container + .createChildContainer() + .resolve(PaymentFormFieldsService); + }); + + it('Should create missed fields and append them into body', () => { + const textInputElements = getTextInputElements([]); + spyOn(windowService.document, 'querySelectorAll').and.returnValue( + textInputElements + ); + const missedFields = ['card']; + const mockBody = { + appendChild: noopStub, + } as unknown as HTMLElement; + spyOn(windowService.document, 'querySelector').and.returnValue( + mockBody as unknown as Element + ); + const spy = spyOn(mockBody, 'appendChild'); + paymentFormFieldsManager.createMissedFields(missedFields, mockBody); + expect(spy).toHaveBeenCalled(); + }); + + it('Should remove extra field', () => { + const extraFields = ['card', null]; + const mockElement = { + remove: noopStub, + }; + spyOn(windowService.document, 'querySelector').and.returnValue( + mockElement as unknown as Element + ); + const spy = spyOn(mockElement, 'remove'); + paymentFormFieldsManager.removeExtraFields(extraFields); + expect(spy).toHaveBeenCalled(); + }); + + it('Should remove fields with empty name', () => { + const textInputElements = getTextInputElements(['zip']); + textInputElements.forEach((element) => { + element.removeAttribute('name'); + }); + spyOn(windowService.document, 'querySelectorAll').and.returnValue( + textInputElements + ); + const spy = spyOn(textInputElements[0], 'remove'); + paymentFormFieldsManager.removeEmptyNameFields(); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts new file mode 100644 index 0000000..2a29d67 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/payment-form-fields.service.ts @@ -0,0 +1,45 @@ +import { injectable } from 'tsyringe'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; + +@injectable() +export class PaymentFormFieldsService { + public constructor(private readonly window: Window) {} + + public createMissedFields( + missedFieldsNames: string[], + container: HTMLElement + ): void { + const documentFragment = this.window.document.createDocumentFragment(); + + for (const name of missedFieldsNames) { + const formElement = this.window.document.createElement( + WebComponentTagName.TextComponent + ); + formElement.setAttribute('name', name); + documentFragment.append(formElement); + } + + container.appendChild(documentFragment); + } + + public removeExtraFields(extraFieldsNames: Array): void { + for (const name of extraFieldsNames) { + if (!name) { + continue; + } + const formElement = this.window.document.querySelector(`[name=${name}]`); + formElement?.remove(); + } + } + + public removeEmptyNameFields(): void { + const formInputs = this.window.document.querySelectorAll( + WebComponentTagName.TextComponent + ); + Array.from(formInputs).forEach((formInput) => { + if (!formInput.getAttribute('name')) { + formInput.remove(); + } + }); + } +} 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 new file mode 100644 index 0000000..6d74e82 --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.spec.ts @@ -0,0 +1,161 @@ +import { container } from 'tsyringe'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; +import { noopStub } from '../../../../tests/stubs/noop.stub'; +import { HeadlessCheckout } from '../../headless-checkout'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { PaymentFormFieldsService } from './payment-form-fields.service'; +import { PaymentFormComponent } from './payment-form.component'; +import { Field } from '../../../../core/form/field.interface'; + +function createComponent(): void { + const element = document.createElement( + WebComponentTagName.PaymentFormComponent + ); + element.setAttribute('id', 'test'); + (document.getElementById('container')! as HTMLElement).appendChild(element); +} + +const mockFormFields: Field[] = [ + { + name: 'zip', + }, + { + name: 'card', + }, +] as unknown as Field[]; + +const getTextInputElements = (names: string[]): NodeListOf => { + const mockContainer = document.createElement('div'); + for (const name of names) { + const mockElement = document.createElement( + WebComponentTagName.TextComponent + ); + mockElement.setAttribute('name', name); + mockContainer.appendChild(mockElement); + } + + return mockContainer.querySelectorAll(WebComponentTagName.TextComponent); +}; + +describe('PaymentFormComponent', () => { + let headlessCheckout: HeadlessCheckout; + let formSpy: FormSpy; + let paymentFormFieldsManager: PaymentFormFieldsService; + let windowService: Window; + + window.customElements.define( + WebComponentTagName.PaymentFormComponent, + PaymentFormComponent + ); + + beforeEach(() => { + document.body.innerHTML = '
'; + + headlessCheckout = { + events: { + send: noopStub, + }, + } as unknown as HeadlessCheckout; + + formSpy = { + listenFormInit: noopStub, + get formWasInit() { + return; + }, + get formFields() { + return; + }, + } as unknown as FormSpy; + + paymentFormFieldsManager = { + createMissedFields: noopStub, + removeExtraFields: noopStub, + removeEmptyNameFields: noopStub, + } as unknown as PaymentFormFieldsService; + + windowService = window; + + container + .register(FormSpy, { + useValue: formSpy, + }) + .register(HeadlessCheckout, { + useValue: headlessCheckout, + }) + .register(PaymentFormFieldsService, { + useValue: paymentFormFieldsManager, + }) + .register(Window, { useValue: windowService }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('Should create component', () => { + createComponent(); + expect( + document.querySelector(WebComponentTagName.PaymentFormComponent) + ).toBeDefined(); + }); + + it('Should load form fields', () => { + const spy = spyOnProperty(formSpy, 'formFields', 'get').and.returnValue([]); + spyOnProperty(formSpy, 'formWasInit', 'get').and.returnValue(true); + createComponent(); + expect(spy).toHaveBeenCalled(); + }); + + it('Should load form fields after init', () => { + const spy = spyOnProperty(formSpy, 'formFields', 'get').and.returnValue([]); + const formWasInitSpy = spyOnProperty(formSpy, 'formWasInit', 'get'); + const listenFormInitSpy = spyOn(formSpy, 'listenFormInit'); + listenFormInitSpy.and.callFake((callback: () => void) => { + formWasInitSpy.and.returnValue(true); + callback(); + }); + formWasInitSpy.and.returnValue(false); + createComponent(); + expect(spy).toHaveBeenCalled(); + }); + + it('Should create missed form fields', () => { + spyOnProperty(formSpy, 'formFields', 'get').and.returnValue(mockFormFields); + spyOnProperty(formSpy, 'formWasInit', 'get').and.returnValue(true); + + const textInputElements = getTextInputElements(['zip']); + spyOn(windowService.document, 'querySelectorAll').and.returnValue( + textInputElements + ); + + const spy = spyOn(paymentFormFieldsManager, 'createMissedFields'); + createComponent(); + + expect(spy).toHaveBeenCalled(); + }); + + it('Should not call createMissedFields if no fields', () => { + spyOnProperty(formSpy, 'formFields', 'get').and.returnValue(undefined); + spyOnProperty(formSpy, 'formWasInit', 'get').and.returnValue(true); + + const spy = spyOn(paymentFormFieldsManager, 'createMissedFields'); + createComponent(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('Should remove extra form fields', () => { + spyOnProperty(formSpy, 'formFields', 'get').and.returnValue(mockFormFields); + spyOnProperty(formSpy, 'formWasInit', 'get').and.returnValue(true); + + const textInputElements = getTextInputElements(['zip', 'zip']); + spyOn(windowService.document, 'querySelectorAll').and.returnValue( + textInputElements + ); + + const spy = spyOn(paymentFormFieldsManager, 'removeExtraFields'); + createComponent(); + + expect(spy).toHaveBeenCalledWith(['zip']); + }); +}); 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 new file mode 100644 index 0000000..567024c --- /dev/null +++ b/src/features/headless-checkout/web-components/payment-form/payment-form.component.ts @@ -0,0 +1,128 @@ +import { WebComponentAbstract } from '../../../../core/web-components/web-component.abstract'; +import { container } from 'tsyringe'; +import { FormSpy } from '../../../../core/spy/form-spy/form-spy'; +import { Field } from '../../../../core/form/field.interface'; +import { HeadlessCheckout } from '../../headless-checkout'; +import { EventName } from '../../../../core/event-name.enum'; +import { getMissedFieldsNames } from './get-missed-fields-names.function'; +import { getInvalidFieldsNames } from './get-invalid-fields-names.function'; +import { PaymentFormFieldsService } from './payment-form-fields.service'; +import { WebComponentTagName } from '../../../../core/web-components/web-component-tag-name.enum'; + +export class PaymentFormComponent extends WebComponentAbstract { + private readonly headlessCheckout: HeadlessCheckout; + private readonly formSpy: FormSpy; + private readonly paymentFormFieldsManager: PaymentFormFieldsService; + + private formExpectedFields?: Field[]; + private readonly window: Window; + private existsControlsNames?: Array; + private expectedFieldsNames!: string[]; + private missedFieldsNames!: string[]; + private invalidFieldsNames!: string[]; + + private get elementRef(): HTMLElement { + return this.querySelector('div')! as HTMLElement; + } + + public constructor() { + super(); + this.headlessCheckout = container.resolve(HeadlessCheckout); + this.formSpy = container.resolve(FormSpy); + this.window = container.resolve(Window); + this.paymentFormFieldsManager = container.resolve(PaymentFormFieldsService); + } + + protected connectedCallback(): void { + if (!this.formSpy.formWasInit) { + this.formSpy.listenFormInit(() => this.connectedCallback()); + return; + } + this.formExpectedFields = this.formSpy.formFields; + + super.render(); + if (this.formExpectedFields) { + this.expectedFieldsNames = this.getExpectedControlsNames( + this.formExpectedFields + ); + this.existsControlsNames = this.getExistsControlsNames(); + + this.setupFormFields(this.expectedFieldsNames, this.existsControlsNames); + } + } + + protected getHtml(): string { + return '
'; + } + + private setupFormFields( + expectedFieldsNames: string[], + existsControlsNames: Array + ): void { + this.missedFieldsNames = getMissedFieldsNames( + expectedFieldsNames, + existsControlsNames + ); + this.setupMissedFields(this.missedFieldsNames); + + this.invalidFieldsNames = getInvalidFieldsNames( + expectedFieldsNames, + existsControlsNames + ); + this.setupInvalidFields(this.invalidFieldsNames); + } + private setupMissedFields(missedFieldsNames: string[]): void { + this.paymentFormFieldsManager.createMissedFields( + missedFieldsNames, + this.elementRef + ); + this.logMissedFields(missedFieldsNames); + } + + private setupInvalidFields(missedFieldsNames: string[]): void { + this.paymentFormFieldsManager.removeExtraFields(missedFieldsNames); + this.paymentFormFieldsManager.removeEmptyNameFields(); + this.logExtraFields(missedFieldsNames); + } + + private getExpectedControlsNames(fields: Field[]): string[] { + return fields.map((field) => field.name); + } + + private getExistsControlsNames(): Array { + const formInputs = this.window.document.querySelectorAll( + WebComponentTagName.TextComponent + ); + return Array.from(formInputs).map((formInput) => + formInput.getAttribute('name') + ); + } + + private logMissedFields(missedFieldsNames: string[]): void { + if (!missedFieldsNames.length) { + return; + } + const message = `This fields were auto created: [${missedFieldsNames.join( + ', ' + )}]. They are mandatory for a payment flow`; + console.warn(message); + void this.headlessCheckout.events.send<{ message: string }>( + { name: EventName.warning, data: { message } }, + () => null + ); + } + + private logExtraFields(extraFieldsNames: Array): void { + if (!extraFieldsNames.length) { + return; + } + const message = `This fields were auto removed: [${extraFieldsNames.join( + ', ' + )}]. They are useless for a payment flow`; + console.warn(message); + void this.headlessCheckout.events.send<{ message: string }>( + { name: EventName.warning, data: { message } }, + () => null + ); + } +}