Skip to content

Commit

Permalink
feat(core): add subpackage for external scripts (#2773)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
damienwebdev committed Apr 24, 2024
1 parent 60bd6db commit 53b0d65
Show file tree
Hide file tree
Showing 14 changed files with 363 additions and 0 deletions.
6 changes: 6 additions & 0 deletions libs/core/external-script/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/index.ts"
}
}
55 changes: 55 additions & 0 deletions libs/core/external-script/src/external-script.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = <Document>{
createElement: (tagName: string) => <HTMLElement>{
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(<Event>{});
});
});

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(<Event>{});
});
});
});
116 changes: 116 additions & 0 deletions libs/core/external-script/src/external-script.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
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<string, LoadedExternalScript> = new Map();
private doc: Document;

constructor(
@Inject(DOCUMENT) doc,
) {
this.doc = doc;
}

/**
* @inheritdoc
*/
load(name: string, script: DaffExternalScript): Observable<boolean> {
// 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<boolean | undefined>(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),
);
}
}
1 change: 1 addition & 0 deletions libs/core/external-script/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public_api';
14 changes: 14 additions & 0 deletions libs/core/external-script/src/interface.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}
3 changes: 3 additions & 0 deletions libs/core/external-script/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { DaffExternalScriptService } from './external-script.service';
export { DaffExternalScript } from './script';
export { DaffExternalScriptServiceInterface } from './interface';
36 changes: 36 additions & 0 deletions libs/core/external-script/src/script.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions libs/core/external-script/testing/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/index.ts"
}
}
4 changes: 4 additions & 0 deletions libs/core/external-script/testing/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const enum TestScripts {
SUCCESS = '0',
FAILURE = '1'
}
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
57 changes: 57 additions & 0 deletions libs/core/external-script/testing/src/external-script.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
switch(name) {
case TestScripts.SUCCESS:
return of(true);
default:
return throwError(() => new Error(`Failed to load ${ script.src }`));
}
}
}
1 change: 1 addition & 0 deletions libs/core/external-script/testing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './public_api';
10 changes: 10 additions & 0 deletions libs/core/external-script/testing/src/provider.ts
Original file line number Diff line number Diff line change
@@ -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,
});
2 changes: 2 additions & 0 deletions libs/core/external-script/testing/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DaffExternalScriptTestingService } from './external-script.service';
export { TestScripts } from './constants';

0 comments on commit 53b0d65

Please sign in to comment.