From 6978926fc3e6fb341f8986523559ea7c8a54a327 Mon Sep 17 00:00:00 2001 From: "e.kireev" Date: Fri, 29 Mar 2024 09:40:49 +0300 Subject: [PATCH] feat(PAYMENTS-18631): add country selector --- src/core/country-response.interface.ts | 5 + src/core/country.interface.ts | 5 + src/core/event-name.enum.ts | 2 + .../country-list-event-message.guard.ts | 20 +++ .../headless-checkout/headless-checkout.ts | 19 +++ .../get-country-list.handler.ts | 29 ++++ .../select/select-attributes.enum.ts | 1 + .../web-components/select/select-type.enum.ts | 4 + .../select/select.component.spec.ts | 22 +++ .../web-components/select/select.component.ts | 151 +++++++++++++----- 10 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 src/core/country-response.interface.ts create mode 100644 src/core/country.interface.ts create mode 100644 src/core/guards/country-list-event-message.guard.ts create mode 100644 src/features/headless-checkout/post-messages-handlers/get-country-list.handler.ts create mode 100644 src/features/headless-checkout/web-components/select/select-type.enum.ts diff --git a/src/core/country-response.interface.ts b/src/core/country-response.interface.ts new file mode 100644 index 0000000..1d2d23c --- /dev/null +++ b/src/core/country-response.interface.ts @@ -0,0 +1,5 @@ +import { Country } from './country.interface'; + +export interface CountryResponse { + countryList: Country[]; +} diff --git a/src/core/country.interface.ts b/src/core/country.interface.ts new file mode 100644 index 0000000..fb64836 --- /dev/null +++ b/src/core/country.interface.ts @@ -0,0 +1,5 @@ +export interface Country { + ISO: string; + aliases: null | string; + name: string; +} diff --git a/src/core/event-name.enum.ts b/src/core/event-name.enum.ts index 9333863..eaaef1a 100644 --- a/src/core/event-name.enum.ts +++ b/src/core/event-name.enum.ts @@ -4,6 +4,7 @@ export const enum EventName { initPayment = 'initPayment', initForm = 'initForm', submitForm = 'submitForm', + getCountryList = 'getCountryList', getPaymentMethodsList = 'getPaymentMethodsList', getPaymentQuickMethods = 'getPaymentQuickMethods', getCombinedPaymentMethods = 'getCombinedPaymentMethods', @@ -32,4 +33,5 @@ export const enum EventName { applePayError = 'applePayError', openApplePayPage = 'openApplePayPage', submitApplePayForm = 'submitApplePayForm', + userCountryChanged = 'userCountryChanged', } diff --git a/src/core/guards/country-list-event-message.guard.ts b/src/core/guards/country-list-event-message.guard.ts new file mode 100644 index 0000000..da9975b --- /dev/null +++ b/src/core/guards/country-list-event-message.guard.ts @@ -0,0 +1,20 @@ +import { Message } from '../../core/message.interface'; +import { isEventMessage } from './event-message.guard'; +import { EventName } from '../../core/event-name.enum'; +import { CountryResponse } from '../country-response.interface'; + +export const isCountryListEventMessage = ( + messageData: unknown, +): messageData is Message<{ + countryList: CountryResponse['countryList']; + currentCountry: string; +}> => { + if (isEventMessage(messageData)) { + return ( + messageData.name === EventName.getCountryList && + (messageData.data as { [key: string]: unknown })?.countryList !== + undefined + ); + } + return false; +}; diff --git a/src/features/headless-checkout/headless-checkout.ts b/src/features/headless-checkout/headless-checkout.ts index 60e79c6..6e8cd19 100644 --- a/src/features/headless-checkout/headless-checkout.ts +++ b/src/features/headless-checkout/headless-checkout.ts @@ -36,6 +36,8 @@ import { CombinedPaymentMethods } from '../../core/combined-payment-methods.inte import { themes } from '../../core/customization/themes.map'; import { ThemesLoader } from '../../core/customization/themes-loader'; import { Lang } from '../../core/i18n/lang.enum'; +import { getCountryListHandler } from './post-messages-handlers/get-country-list.handler'; +import { CountryResponse } from '../../core/country-response.interface'; @singleton() export class HeadlessCheckout { @@ -331,6 +333,23 @@ export class HeadlessCheckout { return this.localizeService.getAvailableLanguages(); } + public async getCountryList(): Promise<{ + countryList: CountryResponse['countryList']; + currentCountry: string; + }> { + const msg: Message = { + name: EventName.getCountryList, + }; + + return this.postMessagesClient.send<{ + countryList: CountryResponse['countryList']; + currentCountry: string; + }>(msg, getCountryListHandler) as Promise<{ + countryList: CountryResponse['countryList']; + currentCountry: string; + }>; + } + private async setupCoreIframe(): Promise { this.coreIframe = this.window.document.createElement('iframe'); this.coreIframe.width = '0px'; diff --git a/src/features/headless-checkout/post-messages-handlers/get-country-list.handler.ts b/src/features/headless-checkout/post-messages-handlers/get-country-list.handler.ts new file mode 100644 index 0000000..57bb757 --- /dev/null +++ b/src/features/headless-checkout/post-messages-handlers/get-country-list.handler.ts @@ -0,0 +1,29 @@ +import { Handler } from '../../../core/post-messages-client/handler.type'; +import { Message } from '../../../core/message.interface'; +import { CountryResponse } from '../../../core/country-response.interface'; +import { isCountryListEventMessage } from '../../../core/guards/country-list-event-message.guard'; + +export const getCountryListHandler: Handler<{ + countryList: CountryResponse['countryList']; + currentCountry: string; +}> = ( + message: Message, +): { + isHandled: boolean; + value: { + countryList: CountryResponse['countryList']; + currentCountry: string; + }; +} | null => { + if (!isCountryListEventMessage(message)) { + return null; + } + + const countryList = message.data?.countryList ?? []; + const currentCountry = message.data?.currentCountry ?? ''; + + return { + isHandled: true, + value: { countryList, currentCountry }, + }; +}; diff --git a/src/features/headless-checkout/web-components/select/select-attributes.enum.ts b/src/features/headless-checkout/web-components/select/select-attributes.enum.ts index bf5e277..bb183f5 100644 --- a/src/features/headless-checkout/web-components/select/select-attributes.enum.ts +++ b/src/features/headless-checkout/web-components/select/select-attributes.enum.ts @@ -1,3 +1,4 @@ export enum SelectAttributes { name = 'name', + type = 'type', } diff --git a/src/features/headless-checkout/web-components/select/select-type.enum.ts b/src/features/headless-checkout/web-components/select/select-type.enum.ts new file mode 100644 index 0000000..a2a2c20 --- /dev/null +++ b/src/features/headless-checkout/web-components/select/select-type.enum.ts @@ -0,0 +1,4 @@ +export enum SelectType { + country = 'country', + formControl = 'form-control', +} 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 46212cc..edbe049 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 @@ -5,6 +5,7 @@ import { WebComponentTagName } from '../../../../core/web-components/web-compone import { HeadlessCheckout } from '../../headless-checkout'; import { SelectAttributes } from './select-attributes.enum'; import { SelectComponent } from './select.component'; +import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy'; function createComponent(name: string): HTMLElement { const element = document.createElement(WebComponentTagName.SelectComponent); @@ -34,6 +35,7 @@ const configMock = { describe('SelectComponent', () => { let postMessagesClient: PostMessagesClient; let headlessCheckout: HeadlessCheckout; + let headlessCheckoutSpy: HeadlessCheckoutSpy; window.customElements.define( WebComponentTagName.SelectComponent, @@ -53,6 +55,13 @@ describe('SelectComponent', () => { }, } as unknown as HeadlessCheckout; + headlessCheckoutSpy = { + listenAppInit: noopStub, + get appWasInit() { + return true; + }, + } as unknown as HeadlessCheckoutSpy; + container.clearInstances(); container @@ -64,11 +73,24 @@ describe('SelectComponent', () => { }) .register(Window, { useValue: window, + }) + .register(HeadlessCheckoutSpy, { + useValue: headlessCheckoutSpy, }); }); afterEach(() => { document.body.innerHTML = ''; + const appWasInitSpy = spyOnProperty( + headlessCheckoutSpy, + 'appWasInit', + 'get', + ); + const listenAppInitSpy = spyOn(headlessCheckoutSpy, 'listenAppInit'); + listenAppInitSpy.and.callFake((callback: () => void) => { + appWasInitSpy.and.returnValue(true); + callback(); + }); }); it('Should create component', () => { 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 148a81f..d2239a8 100644 --- a/src/features/headless-checkout/web-components/select/select.component.ts +++ b/src/features/headless-checkout/web-components/select/select.component.ts @@ -7,6 +7,10 @@ import { SelectAttributes } from './select-attributes.enum'; import { SelectComponentConfig } from './select-component.config.interface'; import './select.component.scss'; import { SelectKeys } from './select-keys.enum'; +import { HeadlessCheckoutSpy } from '../../../../core/spy/headless-checkout-spy/headless-checkout-spy'; +import { container } from 'tsyringe'; +import { EventName } from '../../../../core/event-name.enum'; +import { SelectType } from './select-type.enum'; export class SelectComponent extends BaseControl { protected config: SelectComponentConfig | null = null; @@ -15,6 +19,7 @@ export class SelectComponent extends BaseControl { private isOpened = false; private selectedOptionIndex!: number; private currentHoverOptionIndex!: number; + private readonly headlessCheckoutSpy!: HeadlessCheckoutSpy; private get rootElement(): this | ShadowRoot { return this.shadowRoot ?? this; @@ -26,31 +31,34 @@ export class SelectComponent extends BaseControl { public constructor() { super(); + this.headlessCheckoutSpy = container.resolve(HeadlessCheckoutSpy); } public get nameAttr(): string { return this.getAttribute(SelectAttributes.name) ?? ''; } + public get typeAttr(): string { + return this.getAttribute(SelectAttributes.type) ?? ''; + } + public static get observedAttributes(): string[] { - return [SelectAttributes.name]; + return [SelectAttributes.name, SelectAttributes.name]; } protected connectedCallback(): void { - this.controlName = this.nameAttr; + this.controlName = this.nameAttr || this.typeAttr; if (!this.controlName) { return; } - this.listenFieldStatusChange(); - - void this.getComponentConfig(this.controlName).then((config) => { - this.config = config; - console.log(config); + if (!this.headlessCheckoutSpy.appWasInit) { + this.headlessCheckoutSpy.listenAppInit(() => this.connectedCallback()); + return; + } - this.render(); - }); + this.loadConfig(); } protected attributeChangedCallback(): void { @@ -77,6 +85,45 @@ export class SelectComponent extends BaseControl { this.addHandlers(); } + protected notifyOnFocusEvent(): void { + if (this.typeAttr === SelectType.country) { + return; + } + + super.notifyOnFocusEvent(); + } + + protected notifyOnBlurEvent(): void { + if (this.typeAttr === SelectType.country) { + return; + } + + super.notifyOnBlurEvent(); + } + + protected notifyOnValueChanges(value: unknown): void { + if (this.typeAttr === SelectType.country) { + this.dispatchChangeCountryEvent(value); + + return; + } + super.notifyOnValueChanges(value); + } + + private dispatchChangeCountryEvent(value: unknown): void { + const eventOptions = { + bubbles: true, + composed: true, + detail: { + country: value, + }, + }; + + this.rootElement.dispatchEvent( + new CustomEvent(EventName.userCountryChanged, eventOptions), + ); + } + private toggleDropdown(): void { this.isOpened = !this.isOpened; @@ -126,24 +173,18 @@ export class SelectComponent extends BaseControl { ); this.addEventListenerToElement(button, 'keydown', (event) => { - const { key: keyEvent, code } = event as KeyboardEvent; - const key = keyEvent.toLowerCase(); + const key = (event as KeyboardEvent).key.toLowerCase(); + const code = (event as KeyboardEvent).code.toLowerCase(); const optionsElements = Array.from( this.options!.children, ) as HTMLElement[]; - if (key.length === 1 && /[a-z]/i.test(key)) { + if (/[a-z]/i.test(key) && key.length === 1) { this.handleLetterKey(key, optionsElements); - return; - } - - if (key === SelectKeys.enter || code === SelectKeys.space) { + } else if (key === SelectKeys.enter || code === SelectKeys.space) { this.handleEnterOrSpaceKey(event as KeyboardEvent); - return; - } - - if ( + } else if ( (key === SelectKeys.arrowUp || key === SelectKeys.arrowDown) && this.isOpened ) { @@ -283,38 +324,33 @@ export class SelectComponent extends BaseControl { private onSelectOption(event: Event): void { const eventTarget = event.target as HTMLElement; - if (!eventTarget) { - return; - } + if (!eventTarget) return; const optionValue = this.getOptionValue(eventTarget); const optionIndex = this.getOptionIndex(eventTarget); const previousOption = this.getOptionByIndex(this.selectedOptionIndex); - if (optionValue && optionIndex && previousOption) { - this.removeFocusedClass(previousOption as HTMLElement); + if (!optionValue || !optionIndex || !previousOption) return; - this.selectedOptionValue = optionValue; - this.selectedOptionIndex = this.currentHoverOptionIndex = - Number(optionIndex); + this.removeFocusedClass(previousOption as HTMLElement); - const selectedOptionElement = - this.rootElement.querySelector('#select-content'); + this.selectedOptionValue = optionValue; + this.selectedOptionIndex = this.currentHoverOptionIndex = + Number(optionIndex); - const selectedOption = this.config?.options?.find( - (option) => this.selectedOptionValue === option.value, - ); - - if (selectedOptionElement && eventTarget.parentElement) { - selectedOptionElement.innerHTML = selectedOption?.label ?? ''; + const selectedOptionElement = + this.rootElement.querySelector('#select-content'); + const selectedOption = this.config?.options?.find( + (option) => this.selectedOptionValue === option.value, + ); - this.setFocusedClass(eventTarget.parentElement); + if (!selectedOptionElement || !eventTarget.parentElement) return; - this.closeDropdown(); + selectedOptionElement.innerHTML = selectedOption?.label ?? ''; + this.setFocusedClass(eventTarget.parentElement); - this.notifyOnValueChanges(this.selectedOptionValue); - } - } + this.closeDropdown(); + this.notifyOnValueChanges(this.selectedOptionValue); } private getOptionValue(target: HTMLElement): string | null { @@ -346,4 +382,37 @@ export class SelectComponent extends BaseControl { private removeFocusedClass(element: HTMLElement): void { element.classList.remove('focused'); } + + private setupSelectCountryConfig(): void { + void this.headlessCheckout.getCountryList().then((data) => { + this.config = { + options: data.countryList.map((country) => ({ + label: country.name, + value: country.ISO, + })), + }; + + this.config.initValue = data.currentCountry; + + this.render(); + }); + } + + private loadConfig(): void { + switch (this.typeAttr) { + case SelectType.country: { + this.setupSelectCountryConfig(); + + break; + } + default: + this.listenFieldStatusChange(); + + void this.getComponentConfig(this.controlName).then((config) => { + this.config = config; + + this.render(); + }); + } + } }