From 53b0d653c9a51bf89f6a015825e14f0bcaf76d6c Mon Sep 17 00:00:00 2001 From: Damien Retzinger Date: Wed, 24 Apr 2024 10:17:37 -0400 Subject: [PATCH] feat(core): add subpackage for external scripts (#2773) This adds a new subpackage for loading third-party javascript dynamically in a document. This will likely be useful when interacting with third-party widgets and payment APIs. --- libs/core/external-script/ng-package.json | 6 + .../src/external-script.service.spec.ts | 55 +++++++++ .../src/external-script.service.ts | 116 ++++++++++++++++++ libs/core/external-script/src/index.ts | 1 + libs/core/external-script/src/interface.ts | 14 +++ libs/core/external-script/src/public_api.ts | 3 + libs/core/external-script/src/script.ts | 36 ++++++ .../external-script/testing/ng-package.json | 6 + .../external-script/testing/src/constants.ts | 4 + .../src/external-script.service.spec.ts | 52 ++++++++ .../testing/src/external-script.service.ts | 57 +++++++++ .../core/external-script/testing/src/index.ts | 1 + .../external-script/testing/src/provider.ts | 10 ++ .../external-script/testing/src/public_api.ts | 2 + 14 files changed, 363 insertions(+) create mode 100644 libs/core/external-script/ng-package.json create mode 100644 libs/core/external-script/src/external-script.service.spec.ts create mode 100644 libs/core/external-script/src/external-script.service.ts create mode 100644 libs/core/external-script/src/index.ts create mode 100644 libs/core/external-script/src/interface.ts create mode 100644 libs/core/external-script/src/public_api.ts create mode 100644 libs/core/external-script/src/script.ts create mode 100644 libs/core/external-script/testing/ng-package.json create mode 100644 libs/core/external-script/testing/src/constants.ts create mode 100644 libs/core/external-script/testing/src/external-script.service.spec.ts create mode 100644 libs/core/external-script/testing/src/external-script.service.ts create mode 100644 libs/core/external-script/testing/src/index.ts create mode 100644 libs/core/external-script/testing/src/provider.ts create mode 100644 libs/core/external-script/testing/src/public_api.ts diff --git a/libs/core/external-script/ng-package.json b/libs/core/external-script/ng-package.json new file mode 100644 index 0000000000..0f621f8520 --- /dev/null +++ b/libs/core/external-script/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/core/external-script/src/external-script.service.spec.ts b/libs/core/external-script/src/external-script.service.spec.ts new file mode 100644 index 0000000000..56976f3ccd --- /dev/null +++ b/libs/core/external-script/src/external-script.service.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { TestScheduler } from 'rxjs/testing'; + +import { DaffExternalScriptService } from './external-script.service'; + +export const FAKE_DOCUMENT = { + createElement: (tagName: string) => { + setAttribute: (name: string, value: string) => {}, + setAttributeNS: (namespace: string, name: string, value: string) => {}, + }, + + body: { + appendChild: (node: any) => {}, + }, +}; + +describe('DaffExternalScriptService', () => { + let service: DaffExternalScriptService; + let testScheduler: TestScheduler; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = new DaffExternalScriptService(FAKE_DOCUMENT); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should throw an error when the script errors', () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const expected = '(#)'; + + expectObservable(service.load('test', { src: 'https://example.com/script.js' })) + .toBe(expected, [], new Error('Failed to load https://example.com/script.js')); + service.scriptMap.get('test').el.onerror({}); + }); + }); + + it('should emit true when it loads a success script', () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const expected = 'a'; + + expectObservable(service.load('test', { src: 'https://example.com/script.js' })) + .toBe(expected, { a: true }); + service.scriptMap.get('test').el.onload({}); + }); + }); +}); diff --git a/libs/core/external-script/src/external-script.service.ts b/libs/core/external-script/src/external-script.service.ts new file mode 100644 index 0000000000..9cb2e98270 --- /dev/null +++ b/libs/core/external-script/src/external-script.service.ts @@ -0,0 +1,116 @@ +import { DOCUMENT } from '@angular/common'; +import { + Inject, + Injectable, +} from '@angular/core'; +import { + BehaviorSubject, + Observable, + Subject, +} from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { DaffExternalScriptServiceInterface } from './interface'; +import { DaffExternalScript } from './script'; + +export interface LoadedExternalScript extends DaffExternalScript { + ready: boolean | undefined; + subject: Observable; + el: HTMLElement | undefined; +} + +/** + * A service for loading external scripts into the document. + * + * ### Usage example + * + * The following loads an external script into the document. + * + * ```ts + * import { DOCUMENT } from '@angular/common'; + * import { inject } from '@angular/core'; + * + * import { DaffExternalScriptService } from '@daffodil/core/external-script'; + * + * const externalScriptService = new DaffExternalScriptService(inject(DOCUMENT)); + * + * externalScriptService.load('exampleScript', { + * src: 'https://example.com/script.js', + * async: true, + * defer: false, + * 'data-custom-attribute': 'value', + * }).subscribe({ + * next: (result) => { + * console.log('Script loaded successfully:', result); + * }, + * error: (error) => { + * console.error('Error loading script:', error); + * }, + * }); + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffExternalScriptService implements DaffExternalScriptServiceInterface { + readonly scriptMap: Map = new Map(); + private doc: Document; + + constructor( + @Inject(DOCUMENT) doc, + ) { + this.doc = doc; + } + + /** + * @inheritdoc + */ + load(name: string, script: DaffExternalScript): Observable { + // Don't load the same script twice. + if(this.scriptMap.has(name)){ + return this.scriptMap.get(name).subject; + } + + const scriptEl = this.doc.createElement('script'); + + scriptEl.setAttribute('type', 'text/javascript'); + + scriptEl.setAttribute('src', script.src); + + scriptEl.setAttribute('charset', 'utf-8'); + + // Set custom attributes prefixed with 'data-'. + Object.keys(script).filter(key => key.startsWith('data-')).map((key) => { + // setAttribute would lowercase the value of "key", which isn't always correct. + // setAttributeNS maintains key casing. + scriptEl.setAttributeNS(null, key,script[key]); + }); + + if(script.async) { + scriptEl.async = true; + } + + if(script.defer) { + scriptEl.defer = true; + } + + const readySubject = new BehaviorSubject(undefined); + + scriptEl.onload = () => { + this.scriptMap.get(name).ready = true; + readySubject.next(true); + }; + + scriptEl.onerror = () => { + this.scriptMap.get(name).ready = false; + readySubject.error(new Error(`Failed to load ${ script.src }`)); + }; + + this.doc.body.appendChild(scriptEl); + this.scriptMap.set(name, { ...script, ready: undefined, subject: readySubject, el: scriptEl }); + + return readySubject.pipe( + filter((s) => s !== undefined), + ); + } +} diff --git a/libs/core/external-script/src/index.ts b/libs/core/external-script/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/core/external-script/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/core/external-script/src/interface.ts b/libs/core/external-script/src/interface.ts new file mode 100644 index 0000000000..42bd9ea7cf --- /dev/null +++ b/libs/core/external-script/src/interface.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; + +import { DaffExternalScript } from './script'; + +export interface DaffExternalScriptServiceInterface { + + /** + * Load a script into the document. + * @param name The name of the script. + * @param script The script object containing details like src, async, defer, and custom attributes. + * @returns An Observable that emits true when the script is loaded successfully, or emits an error if loading fails. + */ + load(name: string, script: DaffExternalScript): Observable; +} diff --git a/libs/core/external-script/src/public_api.ts b/libs/core/external-script/src/public_api.ts new file mode 100644 index 0000000000..c003ff72d9 --- /dev/null +++ b/libs/core/external-script/src/public_api.ts @@ -0,0 +1,3 @@ +export { DaffExternalScriptService } from './external-script.service'; +export { DaffExternalScript } from './script'; +export { DaffExternalScriptServiceInterface } from './interface'; diff --git a/libs/core/external-script/src/script.ts b/libs/core/external-script/src/script.ts new file mode 100644 index 0000000000..320003d0d4 --- /dev/null +++ b/libs/core/external-script/src/script.ts @@ -0,0 +1,36 @@ +/** + * Represents the structure of an external script that can be loaded into a document. + * + * ```ts + * // Example of using DaffExternalScript interface to define an external script object. + * + * const externalScript: DaffExternalScript = { + * src: 'https://example.com/script.js', + * async: true, + * defer: false, + * 'data-custom-attribute': 'value', + * }; + * ``` + */ +export interface DaffExternalScript { + /** The source URL of the script. */ + src: string; + + /** + * Optional. Indicates whether the script should be loaded asynchronously. + * @default false + */ + async?: boolean; + + /** + * Optional. Indicates whether the script should be deferred in loading. + * @default false + */ + defer?: boolean; + + /** + * Optional. Custom attributes for the script. + * The keys must start with 'data-' followed by any string. + */ + [key: `data-${string}`]: string; +} diff --git a/libs/core/external-script/testing/ng-package.json b/libs/core/external-script/testing/ng-package.json new file mode 100644 index 0000000000..7dcb29e536 --- /dev/null +++ b/libs/core/external-script/testing/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/core/external-script/testing/src/constants.ts b/libs/core/external-script/testing/src/constants.ts new file mode 100644 index 0000000000..6c0630ff78 --- /dev/null +++ b/libs/core/external-script/testing/src/constants.ts @@ -0,0 +1,4 @@ +export const enum TestScripts { + SUCCESS = '0', + FAILURE = '1' +} diff --git a/libs/core/external-script/testing/src/external-script.service.spec.ts b/libs/core/external-script/testing/src/external-script.service.spec.ts new file mode 100644 index 0000000000..732a207395 --- /dev/null +++ b/libs/core/external-script/testing/src/external-script.service.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { TestScheduler } from 'rxjs/testing'; + +import { TestScripts } from './constants'; +import { DaffExternalScriptTestingService } from './external-script.service'; + +describe('DaffExternalScriptTestingService', () => { + let service: DaffExternalScriptTestingService; + let testScheduler: TestScheduler; + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DaffExternalScriptTestingService); + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should throw an error when given an error script', () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const expected = '(#)'; + + expectObservable(service.load(TestScripts.FAILURE, { src: 'https://example.com/script.js' })) + .toBe(expected, [], new Error('Failed to load https://example.com/script.js')); + }); + }); + + it('should throw an error when given an unknown script', () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const expected = '(#)'; + + expectObservable(service.load('taco', { src: 'https://example.com/script.js' })) + .toBe(expected, [], new Error('Failed to load https://example.com/script.js')); + }); + }); + + it('should emit true when it loads a success script', () => { + testScheduler.run((helpers) => { + const { expectObservable } = helpers; + const expected = '(a|)'; + + expectObservable(service.load(TestScripts.SUCCESS, { src: 'https://example.com/script.js' })) + .toBe(expected, { a: true }); + }); + }); +}); diff --git a/libs/core/external-script/testing/src/external-script.service.ts b/libs/core/external-script/testing/src/external-script.service.ts new file mode 100644 index 0000000000..e191cb3c49 --- /dev/null +++ b/libs/core/external-script/testing/src/external-script.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of, + throwError, +} from 'rxjs'; + +import { DaffExternalScriptServiceInterface } from '@daffodil/core/external-script'; +import { DaffExternalScript } from '@daffodil/core/external-script'; + +import { TestScripts } from './constants'; + +/** + * A service for loading external scripts into the document. + * + * ### Usage example + * + * The following loads an external script into the document. + * + * ```ts + * + * import { DaffExternalScriptTestingService } from '@daffodil/core/external-script/testing'; + * + * const externalScriptService = new DaffExternalScriptTestingService(); + * + * externalScriptService.load(TestScripts.SUCCESS, { + * src: 'https://example.com/script.js', + * async: true, + * defer: false, + * 'data-custom-attribute': 'value', + * }).subscribe({ + * next: (result) => { + * console.log('Script loaded successfully:', result); + * }, + * error: (error) => { + * console.error('Error loading script:', error); + * }, + * }); + * ``` + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffExternalScriptTestingService implements DaffExternalScriptServiceInterface { + + /** + * @inheritdoc + */ + load(name: string, script: DaffExternalScript): Observable { + switch(name) { + case TestScripts.SUCCESS: + return of(true); + default: + return throwError(() => new Error(`Failed to load ${ script.src }`)); + } + } +} diff --git a/libs/core/external-script/testing/src/index.ts b/libs/core/external-script/testing/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/core/external-script/testing/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/core/external-script/testing/src/provider.ts b/libs/core/external-script/testing/src/provider.ts new file mode 100644 index 0000000000..6ae3b6eb23 --- /dev/null +++ b/libs/core/external-script/testing/src/provider.ts @@ -0,0 +1,10 @@ +import { Provider } from '@angular/core'; + +import { DaffExternalScriptService } from '@daffodil/core/external-script'; + +import { DaffExternalScriptTestingService } from './external-script.service'; + +export const provideTestExternalScript: Provider = ({ + provide: DaffExternalScriptService, + useExisting: DaffExternalScriptTestingService, +}); diff --git a/libs/core/external-script/testing/src/public_api.ts b/libs/core/external-script/testing/src/public_api.ts new file mode 100644 index 0000000000..861b6db587 --- /dev/null +++ b/libs/core/external-script/testing/src/public_api.ts @@ -0,0 +1,2 @@ +export { DaffExternalScriptTestingService } from './external-script.service'; +export { TestScripts } from './constants';