@notabene/javascript-sdk 1.34.0 → 2.0.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import {
2
+ CMType,
3
+ ComponentRequest,
4
+ HMType,
5
+ HostMessage,
6
+ ValidationError,
7
+ } from '../types';
8
+ import {
9
+ MessageCallback,
10
+ MessageEventManager,
11
+ } from '../utils/MessageEventManager';
12
+
13
+ /**
14
+ * An embedded Notabene component
15
+ * @public
16
+ */
17
+
18
+ export default class EmbeddedComponent<V, R> {
19
+ private _url: string;
20
+ private _value: V;
21
+ private _errors: ValidationError[] = [];
22
+
23
+ private iframe?: HTMLIFrameElement;
24
+ private eventManager: MessageEventManager;
25
+ private modal?: HTMLDialogElement;
26
+
27
+ /**
28
+ * Creates an instance of EmbeddedComponent.
29
+ * @param url - The URL of the embedded component
30
+ * @param value - The initial transaction value
31
+ */
32
+
33
+ constructor(url: string, value: V) {
34
+ this._url = url;
35
+ this._value = value;
36
+ this.eventManager = new MessageEventManager();
37
+ this.on(CMType.INVALID, (message) => {
38
+ if (message.type === CMType.INVALID) {
39
+ this._errors = message.errors;
40
+ this._value = message.value as V;
41
+ }
42
+ });
43
+ this.on(CMType.RESIZE, (message) => {
44
+ if (message.type === CMType.RESIZE) {
45
+ if (this.iframe) this.iframe.style.height = `${message.height}px`;
46
+ }
47
+ });
48
+ }
49
+ /**
50
+ * Gets the URL of the embedded component
51
+ * @returns The URL of the embedded component
52
+ */
53
+
54
+ public get url(): string {
55
+ return this._url;
56
+ }
57
+ /**
58
+ * Gets the current transaction value
59
+ * @returns The current transaction value
60
+ */
61
+
62
+ public get value(): V {
63
+ return this._value;
64
+ }
65
+
66
+ public get errors(): ValidationError[] {
67
+ return this._errors;
68
+ }
69
+
70
+ /**
71
+ * Opens the component URL in the current window
72
+ */
73
+
74
+ public open() {
75
+ document.location.href = this.url;
76
+ }
77
+
78
+ /**
79
+ * Mounts the component to a parent element
80
+ * @param parentId - The ID of the parent element
81
+ * @throws Will throw an error if the parent element is not found
82
+ */
83
+
84
+ mount(parentId: string) {
85
+ const parent = document.querySelector(parentId);
86
+ if (!parent) throw new Error(`parentID ${parentId} not found`);
87
+ this.embed(parent);
88
+ }
89
+ /**
90
+ * Embeds the component into a parent element
91
+ * @param parent - The parent element to embed the component into
92
+ */
93
+
94
+ embed(parent: Element) {
95
+ this.iframe = document.createElement('iframe');
96
+ this.iframe.src = this.url;
97
+ this.iframe.style.width = '100%';
98
+ this.iframe.style.height = '100px';
99
+ this.iframe.style.border = 'none';
100
+ this.iframe.style.overflow = 'hidden';
101
+ this.iframe.setAttribute('allowtransparency', 'true');
102
+ this.iframe.style.backgroundColor = 'transparent';
103
+ parent.appendChild(this.iframe);
104
+
105
+ window.addEventListener('message', (event: MessageEvent) => {
106
+ if (event.source !== this.iframe?.contentWindow) {
107
+ return;
108
+ }
109
+ console.log('received message from iframe', event.data);
110
+ this.eventManager?.setPort(event.ports[0]);
111
+ });
112
+
113
+ this.iframe?.contentWindow?.focus();
114
+ }
115
+
116
+ /**
117
+ * Sends a message to the embedded component
118
+ * @param message - The message to send
119
+ */
120
+ send(message: HostMessage): void {
121
+ this.eventManager.send(message);
122
+ }
123
+
124
+ /**
125
+ * Adds an event listener for a specific message type
126
+ * @param messageType - The type of message to listen for
127
+ * @param callback - The callback function to execute when the message is received
128
+ */
129
+
130
+ on(messageType: string, callback: MessageCallback): void {
131
+ this.eventManager.on(messageType, callback);
132
+ }
133
+
134
+ /**
135
+ * Removes an event listener for a specific message type
136
+ * @param messageType - The type of message to stop listening for
137
+ * @param callback - The callback function to remove
138
+ */
139
+
140
+ off(messageType: string, callback: MessageCallback): void {
141
+ this.eventManager.off(messageType, callback);
142
+ }
143
+
144
+ /**
145
+ * Updates the transaction value and sends an update message to the component
146
+ * @param value - The new transaction value
147
+ */
148
+ update(value: V) {
149
+ this._value = value;
150
+ this.send({ type: HMType.UPDATE, value: value as ComponentRequest });
151
+ }
152
+
153
+ /**
154
+ * Waits for the component to complete and returns the transaction response
155
+ * @returns A promise that resolves with the transaction response
156
+ */
157
+
158
+ completion(): Promise<R> {
159
+ return new Promise((resolve, reject) => {
160
+ this.on(CMType.COMPLETE, (message) => {
161
+ resolve((message as any).response);
162
+ });
163
+ this.on('error', (message) => {
164
+ reject(new Error((message as any).message));
165
+ });
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Opens the component in a modal dialog
171
+ */
172
+
173
+ openModal(): Promise<R> {
174
+ if (this.modal) {
175
+ this.closeModal();
176
+ }
177
+ this.modal = document.createElement('dialog');
178
+ this.modal.style.border = 'none';
179
+ this.modal.style.backgroundColor = 'transparent';
180
+ this.modal.style.maxWidth = '100vw';
181
+ this.modal.style.maxHeight = '100vh';
182
+ this.modal.style.width = '600px';
183
+ this.modal.style.height = '800px';
184
+
185
+ document.body.appendChild(this.modal);
186
+ this.embed(this.modal);
187
+ this.on(CMType.CANCEL, () => {
188
+ this.closeModal();
189
+ });
190
+ this.on(CMType.CLOSE, () => {
191
+ this.closeModal();
192
+ });
193
+ this.on(CMType.COMPLETE, () => {
194
+ this.closeModal();
195
+ });
196
+
197
+ this.modal.showModal();
198
+ this.modal.addEventListener('click', () => {
199
+ // console.log('click');
200
+ this.closeModal();
201
+ });
202
+ return this.completion();
203
+ }
204
+ /**
205
+ * Closes the modal dialog
206
+ *
207
+ */
208
+
209
+ closeModal(): void {
210
+ if (this.modal) {
211
+ console.log('closeModal');
212
+ this.modal?.close();
213
+ this.modal.remove();
214
+ this.modal = undefined;
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,188 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import {
3
+ CMType,
4
+ HMType,
5
+ HostMessage,
6
+ Transaction,
7
+ TransactionResponse,
8
+ Withdrawal,
9
+ } from '../../types';
10
+ import EmbeddedComponent from '../EmbeddedComponent';
11
+
12
+ const url = 'https://example.com';
13
+ const parentId = 'parent-container';
14
+ const value: Partial<Transaction> = { amountDecimal: 100 };
15
+
16
+ describe('EmbeddedComponent', () => {
17
+ describe('creation', () => {
18
+ let component: EmbeddedComponent<
19
+ Partial<Withdrawal>,
20
+ TransactionResponse<Withdrawal>
21
+ >;
22
+ beforeEach(() => {
23
+ component = new EmbeddedComponent<
24
+ Partial<Withdrawal>,
25
+ TransactionResponse<Withdrawal>
26
+ >(url, { amountDecimal: 100 });
27
+ });
28
+
29
+ test('sets value', () => {
30
+ expect(component.value).toStrictEqual(value);
31
+ });
32
+
33
+ test('sets url', () => {
34
+ expect(component.url).toBe(url);
35
+ });
36
+ });
37
+
38
+ describe('iframe', () => {
39
+ let document: Document;
40
+ let parentElement: HTMLElement;
41
+ let iframe: HTMLIFrameElement;
42
+ let window: Window;
43
+ let iframeWindow: Window;
44
+
45
+ beforeEach(() => {
46
+ parentElement = {
47
+ appendChild: vi.fn(),
48
+ } as unknown as HTMLElement;
49
+
50
+ iframe = {
51
+ style: {},
52
+ src: '',
53
+ contentWindow: iframeWindow,
54
+ setAttribute: vi.fn(),
55
+ addEventListener: vi.fn(),
56
+ } as unknown as HTMLIFrameElement;
57
+
58
+ document = {
59
+ createElement: vi.fn().mockReturnValue(iframe),
60
+ querySelector: vi.fn().mockReturnValue(parentElement),
61
+ } as unknown as Document;
62
+
63
+ window = {
64
+ addEventListener: vi.fn(),
65
+ } as unknown as Window;
66
+
67
+ vi.stubGlobal('document', document);
68
+ vi.stubGlobal('window', window);
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.unstubAllGlobals();
73
+ });
74
+
75
+ test('mount', () => {
76
+ const component = new EmbeddedComponent<
77
+ Partial<Withdrawal>,
78
+ TransactionResponse<Withdrawal>
79
+ >(url, value);
80
+ component.mount(parentId);
81
+ expect(iframe?.src).toBe(url);
82
+ expect(parentElement.appendChild).toHaveBeenCalledWith(iframe);
83
+ });
84
+
85
+ test('on', () => {
86
+ const component = new EmbeddedComponent<
87
+ Partial<Withdrawal>,
88
+ TransactionResponse<Withdrawal>
89
+ >(url, value);
90
+ const messageType = 'customEvent';
91
+ const callback = vi.fn();
92
+ component.on(messageType, callback);
93
+ component.mount(parentId);
94
+
95
+ // Simulate receiving a message of the specified type
96
+ // and assert that the callback is called with the correct payload.
97
+ });
98
+
99
+ test('off', () => {
100
+ const component = new EmbeddedComponent<
101
+ Partial<Withdrawal>,
102
+ TransactionResponse<Withdrawal>
103
+ >(url, value);
104
+ const messageType = 'customEvent';
105
+ const callback = vi.fn();
106
+ component.on(messageType, callback);
107
+
108
+ // Simulate receiving a message of the specified type
109
+ // and assert that the callback is called with the correct payload.
110
+
111
+ component.off(messageType, callback);
112
+
113
+ // Simulate receiving another message of the specified type
114
+ // and assert that the callback is not called.
115
+ });
116
+
117
+ describe('connected to iframe', () => {
118
+ let messageHandler: (event: MessageEvent) => void;
119
+ let port: MessagePort;
120
+ let handshake: MessageEvent;
121
+ let component: EmbeddedComponent<
122
+ Partial<Withdrawal>,
123
+ TransactionResponse<Withdrawal>
124
+ >;
125
+
126
+ beforeEach(() => {
127
+ port = {
128
+ postMessage: vi.fn(),
129
+ start: vi.fn(),
130
+ onmessage: null,
131
+ } as unknown as MessagePort;
132
+ handshake = {
133
+ source: iframe.contentWindow,
134
+ ports: [port],
135
+ } as unknown as MessageEvent;
136
+ window.addEventListener = vi
137
+ .fn()
138
+ .mockImplementation((event, handler) => {
139
+ messageHandler = handler;
140
+ });
141
+ component = new EmbeddedComponent<
142
+ Partial<Withdrawal>,
143
+ TransactionResponse<Withdrawal>
144
+ >(url, value);
145
+ component.mount(parentId);
146
+
147
+ expect(messageHandler).toBeDefined();
148
+ messageHandler(handshake);
149
+ });
150
+
151
+ test('send', () => {
152
+ const message: HostMessage = {
153
+ type: HMType.UPDATE,
154
+ value: {
155
+ requestId: 'id',
156
+ },
157
+ };
158
+ component.send(message);
159
+ expect(port.postMessage).toHaveBeenCalledWith(message);
160
+ });
161
+
162
+ test('update', () => {
163
+ const newValue: Partial<Transaction> = { amountDecimal: 200 };
164
+ component.update(newValue);
165
+
166
+ expect(component.value).toBe(newValue);
167
+ });
168
+
169
+ // TODO not sure this actually does what I want it to do
170
+ test('completion', async () => {
171
+ const response = { valid: true, value: 'completed' };
172
+ expect(port.onmessage).toBeDefined();
173
+
174
+ const promise = component.completion();
175
+ // @ts-expect-error - we've already verified that port exists
176
+ port.onmessage({
177
+ data: { type: CMType.COMPLETE, response },
178
+ } as MessageEvent);
179
+
180
+ promise.then((result) => {
181
+ expect(result).toBe(response);
182
+ });
183
+ return promise;
184
+ // Assert that the completion promise resolves with the expected response.
185
+ });
186
+ });
187
+ });
188
+ });