Skip to content

Commit

Permalink
Merge pull request #21 from xsolla/PAYMENTS-15244
Browse files Browse the repository at this point in the history
feat(PAYMENTS-15244): add payment-form component
  • Loading branch information
Aleksey-Kornienko-xsolla authored Aug 16, 2023
2 parents 964fc74 + 2d154ad commit 2bdbc45
Show file tree
Hide file tree
Showing 17 changed files with 523 additions and 10 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/core/event-name.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const enum EventName {
legalComponentPong = 'legalComponentPong',
financeDetails = 'financeDetails',
nextAction = 'nextAction',
warning = 'warning',
}
2 changes: 1 addition & 1 deletion src/core/post-messages-client/handler.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { Message } from '../message.interface';

export type Handler<T> = (
data: Message,
callback?: () => void
callback?: (args?: unknown) => void
) => { isHandled: boolean; value?: T } | null;
4 changes: 2 additions & 2 deletions src/core/spy/form-spy/form-spy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
13 changes: 10 additions & 3 deletions src/core/spy/form-spy/form-spy.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
}
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 @@ -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',
}
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 @@ -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;
Expand All @@ -17,4 +18,5 @@ export const webComponents: {
[WebComponentTagName.FinanceDetailsComponent]: FinanceDetailsComponent,
[WebComponentTagName.LegalComponent]: LegalComponent,
[WebComponentTagName.StatusComponent]: StatusComponent,
[WebComponentTagName.PaymentFormComponent]: PaymentFormComponent,
};
8 changes: 7 additions & 1 deletion src/features/headless-checkout/headless-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,7 +72,12 @@ export class HeadlessCheckout {
};

return this.postMessagesClient.send<Form>(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<Form>;
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { Handler } from '../../../core/post-messages-client/handler.type';

export const initFormHandler: Handler<Form> = (
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function getInvalidFieldsNames(
expectedFieldsNames: string[],
existsControlsNames: Array<string | null>
): 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;
}
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function getMissedFieldsNames(
expectedFieldsNames: string[],
existsControlsNames: Array<string | null>
): 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;
}
Original file line number Diff line number Diff line change
@@ -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<Element> => {
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>(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();
});
});
Original file line number Diff line number Diff line change
@@ -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<string | null>): 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();
}
});
}
}
Loading

0 comments on commit 2bdbc45

Please sign in to comment.