-
-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
1 parent
60bd6db
commit 53b0d65
Showing
14 changed files
with
363 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
55
libs/core/external-script/src/external-script.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
116
libs/core/external-script/src/external-script.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './public_api'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export const enum TestScripts { | ||
SUCCESS = '0', | ||
FAILURE = '1' | ||
} |
52 changes: 52 additions & 0 deletions
52
libs/core/external-script/testing/src/external-script.service.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
57
libs/core/external-script/testing/src/external-script.service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }`)); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './public_api'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { DaffExternalScriptTestingService } from './external-script.service'; | ||
export { TestScripts } from './constants'; |