Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: added select component #37

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/core/event-name.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ export const enum EventName {
setSecureComponentStyles = 'setSecureComponentStyles',
formFieldsStatusChanged = 'formFieldsStatusChanged',
updateCreditCardType = 'updateCreditCardType',
publicControlChangeState = 'publicControlChangeState',
publicControlOnValueChanges = 'publicControlOnValueChanges',
}
16 changes: 16 additions & 0 deletions src/core/guards/control-config-event-message.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ControlComponentConfig } from '../../features/headless-checkout/web-components/control-component-config.interface';
import { Message } from '../message.interface';
import { EventName } from '../event-name.enum';
import { isEventMessage } from './event-message.guard';

export const isControlConfigEventMessage = (
messageData: unknown,
): messageData is Message<{ config: ControlComponentConfig }> => {
if (isEventMessage(messageData)) {
return (
messageData.name === EventName.getControlComponentConfig &&
(messageData.data as { [key: string]: unknown })?.config !== undefined
);
}
return false;
};
16 changes: 0 additions & 16 deletions src/core/guards/text-config-event-message.guard.ts
Original file line number Diff line number Diff line change
@@ -1,16 +0,0 @@
import { Message } from '../../core/message.interface';
import { isEventMessage } from './event-message.guard';
import { EventName } from '../../core/event-name.enum';
import { TextComponentConfig } from '../../features/headless-checkout/web-components/text-component/text.component.config.interface';

export const isTextConfigEventMessage = (
messageData: unknown
): messageData is Message<{ config: TextComponentConfig }> => {
if (isEventMessage(messageData)) {
return (
messageData.name === EventName.getControlComponentConfig &&
(messageData.data as { [key: string]: unknown })?.config !== undefined
);
}
return false;
};
4 changes: 2 additions & 2 deletions src/core/post-messages-client/post-messages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class PostMessagesClient {
public listen<T>(
eventName: EventName,
handler: Handler<T>,
callback: (value?: T) => void
callback: (value?: T) => void,
): () => void {
const handlerWrapper = (message: MessageEvent): void => {
if (this.isSameOrigin(message.origin)) {
Expand All @@ -68,7 +68,7 @@ export class PostMessagesClient {
private sendMessage(message: Message): void {
this.recipient.contentWindow?.postMessage(
JSON.stringify(message),
this.recipientUrl
this.recipientUrl,
);
}

Expand Down
1 change: 1 addition & 0 deletions src/core/web-components/web-component-tag-name.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export enum WebComponentTagName {
PhoneComponent = 'psdk-phone',
CardNumberComponent = 'psdk-card-number',
ThreeDsComponent = 'psdk-3ds',
SelectComponent = 'psdk-select',
}
4 changes: 2 additions & 2 deletions src/core/web-components/web-component.abstract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export abstract class WebComponentAbstract extends HTMLElement {
protected eventListeners: Array<{
element: HTMLElement;
element: Element;
eventType: string;
listener(event: Event): void;
}> = [];
Expand All @@ -16,7 +16,7 @@ export abstract class WebComponentAbstract extends HTMLElement {
}

protected addEventListenerToElement(
element: HTMLElement,
element: Element,
eventType: string,
listener: (event: Event) => void,
): void {
Expand Down
2 changes: 2 additions & 0 deletions src/core/web-components/web-components.map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PaymentFormComponent } from '../../features/headless-checkout/web-compo
import { CardNumberComponent } from '../../features/headless-checkout/web-components/card-number/card-number.component';
import { ThreeDsComponent } from '../../features/headless-checkout/web-components/three-ds/three-ds.component';
import { PhoneComponent } from '../../features/headless-checkout/web-components/phone-component/phone.component';
import { SelectComponent } from '../../features/headless-checkout/web-components/select/select.component';

export const webComponents: {
[key in WebComponentTagName]: CustomElementConstructor;
Expand All @@ -25,4 +26,5 @@ export const webComponents: {
[WebComponentTagName.CardNumberComponent]: CardNumberComponent,
[WebComponentTagName.ThreeDsComponent]: ThreeDsComponent,
[WebComponentTagName.PhoneComponent]: PhoneComponent,
[WebComponentTagName.SelectComponent]: SelectComponent,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { container } from 'tsyringe';
import { WebComponentAbstract } from '../../../../core/web-components/web-component.abstract';
import { PostMessagesClient } from '../../../../core/post-messages-client/post-messages-client';
import { EventName } from '../../../../core/event-name.enum';
import { Message } from '../../../../core/message.interface';
import { ValidationErrors } from '../../../../core/form/validation-errors.interface';
import { HeadlessCheckout } from '../../headless-checkout';
import { ControlComponentConfig } from '../control-component-config.interface';
import { getControlComponentConfigHandler } from '../get-control-component-config.handler';
import {
publicControlChangeState,
publicControlOnValueChanges,
} from './element.handlers';
import { ElementEventName } from './element-events.enum';

export abstract class BaseControl extends WebComponentAbstract {
protected postMessagesClient: PostMessagesClient;
protected headlessCheckout: HeadlessCheckout;
protected window: Window;

protected controlName!: string;
protected config: ControlComponentConfig | null = null;
protected isListeningFieldStatusChange = false;

protected constructor() {
super();

this.postMessagesClient = container.resolve(PostMessagesClient);
this.headlessCheckout = container.resolve(HeadlessCheckout);
this.window = container.resolve(Window);
}

protected async getComponentConfig(
inputName: string,
): Promise<ControlComponentConfig> {
const msg: Message<{ inputName: string }> = {
name: EventName.getControlComponentConfig,
data: {
inputName,
},
};

return this.postMessagesClient.send<ControlComponentConfig>(
msg,
(message) => {
return getControlComponentConfigHandler(message, (controlName) => {
return msg.data?.inputName === controlName;
});
},
) as Promise<ControlComponentConfig>;
}

protected notifyOnValueChanges(value: unknown): void {
void this.postMessagesClient.send(
{
name: EventName.publicControlOnValueChanges,
data: {
fieldName: this.controlName,
value,
},
},
publicControlOnValueChanges,
);
}

protected notifyOnFocusEvent(): void {
void this.postMessagesClient.send(
{
name: EventName.publicControlChangeState,
data: {
fieldName: this.controlName,
event: ElementEventName.focus,
},
},
publicControlChangeState,
);
}

protected notifyOnBlurEvent(): void {
void this.postMessagesClient.send(
{
name: EventName.publicControlChangeState,
data: {
fieldName: this.controlName,
event: ElementEventName.blur,
},
},
publicControlChangeState,
);
}

protected listenFieldStatusChange(): void {
if (this.isListeningFieldStatusChange) {
return;
}

this.isListeningFieldStatusChange = true;
this.headlessCheckout.form.onFieldsStatusChange((fieldsStatus) => {
const fieldStatus = fieldsStatus[this.controlName];

if (!this.config || !fieldStatus) {
return;
}

this.config.error = this.getErrorFromFieldStatus(fieldStatus.errors);
this.updateError(fieldStatus.isFocused);
});
}

protected getErrorFromFieldStatus(
errors: ValidationErrors | null,
): string | null {
if (!errors) {
return null;
}

const firstErrorKey: string = Object.keys(errors)[0];
return errors[firstErrorKey]?.message ?? null;
}

protected updateError(isFieldInFocus: boolean | undefined): void {
const rootElement = this.shadowRoot ?? this;
const errorElement = rootElement.querySelector('.field-error');

if (this.config?.error && !isFieldInFocus) {
if (!errorElement) {
const newErrorElement = this.window.document.createElement('div');
newErrorElement.classList.add('field-error');
newErrorElement.textContent = this.config.error;
rootElement.appendChild(newErrorElement);
} else {
errorElement.textContent = this.config.error;
}
} else {
if (errorElement) {
errorElement.remove();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum ElementEventName {
blur = 'blur',
focus = 'focus',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Handler } from '../../../../core/post-messages-client/handler.type';
import { Message } from '../../../../core/message.interface';
import { EventName } from '../../../../core/event-name.enum';

export const publicControlChangeState: Handler<void> = (
message: Message,
): { isHandled: boolean } | null => {
if (message.name === EventName.publicControlChangeState) {
return {
isHandled: true,
};
}
return null;
};

export const publicControlOnValueChanges: Handler<void> = (
message: Message,
): { isHandled: boolean } | null => {
if (message.name === EventName.publicControlOnValueChanges) {
return {
isHandled: true,
};
}
return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TextConfigTooltip } from './text-component/text-config-tooltip.interface';

export interface ControlComponentConfig {
controlType?: string;
dataType?: string;
name?: string;
title?: string;
placeholder?: string;
readonly?: boolean;
options?: Array<{ label: string; value: string }>;
tooltip?: TextConfigTooltip;
error?: string | null;
additionalControls?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Handler } from '../../../core/post-messages-client/handler.type';
import { ControlComponentConfig } from './control-component-config.interface';
import { Message } from '../../../core/message.interface';
import { isControlConfigEventMessage } from '../../../core/guards/control-config-event-message.guard';

export const getControlComponentConfigHandler: Handler<
ControlComponentConfig
> = (
message: Message,
callback?: (args?: unknown) => boolean | void,
): { isHandled: boolean; value: ControlComponentConfig } | null => {
if (isControlConfigEventMessage(message)) {
const config = message.data?.config;

if (typeof callback !== 'function' || !callback(config?.name)) {
return null;
}

return {
isHandled: true,
value: config!,
};
}

return null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { WebComponentTagName } from '../../../../core/web-components/web-compone
export const formControlsTags = [
WebComponentTagName.TextComponent,
WebComponentTagName.PhoneComponent,
WebComponentTagName.SelectComponent,
];
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PostMessagesClient } from '../../../../core/post-messages-client/post-m

function createComponent(): void {
const element = document.createElement(
WebComponentTagName.PaymentFormComponent
WebComponentTagName.PaymentFormComponent,
);
element.setAttribute('id', 'test');
(document.getElementById('container')! as HTMLElement).appendChild(element);
Expand All @@ -29,7 +29,7 @@ const getTextInputElements = (names: string[]): NodeListOf<Element> => {
const mockContainer = document.createElement('div');
for (const name of names) {
const mockElement = document.createElement(
WebComponentTagName.TextComponent
WebComponentTagName.TextComponent,
);
mockElement.setAttribute('name', name);
mockContainer.appendChild(mockElement);
Expand All @@ -47,7 +47,7 @@ describe('PaymentFormComponent', () => {

window.customElements.define(
WebComponentTagName.PaymentFormComponent,
PaymentFormComponent
PaymentFormComponent,
);

beforeEach(() => {
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('PaymentFormComponent', () => {
it('Should create component', () => {
createComponent();
expect(
document.querySelector(WebComponentTagName.PaymentFormComponent)
document.querySelector(WebComponentTagName.PaymentFormComponent),
).toBeDefined();
});

Expand Down Expand Up @@ -139,7 +139,7 @@ describe('PaymentFormComponent', () => {

const textInputElements = getTextInputElements(['zip']);
spyOn(windowService.document, 'querySelectorAll').and.returnValue(
textInputElements
textInputElements,
);

const spy = spyOn(paymentFormFieldsManager, 'createMissedFields');
Expand All @@ -164,12 +164,13 @@ describe('PaymentFormComponent', () => {

const textInputElements = getTextInputElements(['zip', 'zip']);
spyOn(windowService.document, 'querySelectorAll').and.returnValue(
textInputElements
textInputElements,
);

const spy = spyOn(paymentFormFieldsManager, 'removeExtraFields');
createComponent();

expect(spy).toHaveBeenCalledWith(['zip', 'zip', 'zip']);
// rewrite querySelectorAll mock
expect(spy).toHaveBeenCalledWith(['zip', 'zip', 'zip', 'zip', 'zip']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { WebComponentTagName } from '../../../../core/web-components/web-compone
export const webComponentsFieldsNamesMap: { [k: string]: WebComponentTagName } =
{
phone: WebComponentTagName.PhoneComponent,
select: WebComponentTagName.SelectComponent,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum SelectAttributes {
name = 'name',
}
Loading