feat(elements): injector create (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin
2018-03-06 14:02:25 -08:00
committed by Miško Hevery
parent 46efd4b938
commit 87f60bccfd
21 changed files with 275 additions and 143 deletions

View File

@ -4,7 +4,7 @@
"uncompressed": {
"inline": 2062,
"main": 467103,
"polyfills": 55349,
"polyfills": 54292,
"embedded": 71711,
"prettify": 14888
}

View File

@ -30,7 +30,7 @@ describe('CodeExampleComponent', () => {
});
it('should be able to capture the code snippet provided in content', () => {
expect(codeExampleComponent.code.trim()).toBe(`const foo = "bar";`);
expect(codeExampleComponent.aioCode.code.trim()).toBe(`const foo = "bar";`);
});
it('should change aio-code classes based on title presence', () => {

View File

@ -34,8 +34,6 @@ import { CodeComponent } from './code.component';
export class CodeExampleComponent implements AfterViewInit {
classes: {};
code: string;
@Input() language: string;
@Input() linenums: string;

View File

@ -9,8 +9,6 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import { ElementsLoader } from './elements-loader';
import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry';
const actualCustomElements = window.customElements;
class FakeComponentFactory extends ComponentFactory<any> {
selector: string;
componentType: Type<any>;
@ -29,21 +27,26 @@ class FakeComponentFactory extends ComponentFactory<any> {
}
const FAKE_COMPONENT_FACTORIES = new Map([
['element-a-module-path', new FakeComponentFactory('element-a-input')]
['element-a-module-path', new FakeComponentFactory('element-a-input')],
['element-b-module-path', new FakeComponentFactory('element-b-input')],
]);
fdescribe('ElementsLoader', () => {
describe('ElementsLoader', () => {
let elementsLoader: ElementsLoader;
let injectedModuleRef: NgModuleRef<any>;
let fakeCustomElements;
let actualCustomElementsDefine;
let fakeCustomElementsDefine;
// ElementsLoader uses the window's customElements API. Provide a fake for this test.
beforeEach(() => {
fakeCustomElements = jasmine.createSpyObj('customElements', ['define', 'whenDefined']);
window.customElements = fakeCustomElements;
actualCustomElementsDefine = window.customElements.define;
fakeCustomElementsDefine = jasmine.createSpy('define');
window.customElements.define = fakeCustomElementsDefine;
});
afterEach(() => {
window.customElements = actualCustomElements;
window.customElements.define = actualCustomElementsDefine;
});
beforeEach(() => {
@ -52,7 +55,8 @@ fdescribe('ElementsLoader', () => {
ElementsLoader,
{ provide: NgModuleFactoryLoader, useClass: FakeModuleFactoryLoader },
{ provide: ELEMENT_MODULE_PATHS_TOKEN, useValue: new Map([
['element-a-selector', 'element-a-module-path']
['element-a-selector', 'element-a-module-path'],
['element-b-selector', 'element-b-module-path']
])},
]
});
@ -71,7 +75,7 @@ fdescribe('ElementsLoader', () => {
elementsLoader.loadContainingCustomElements(hostEl);
tick();
const defineArgs = fakeCustomElements.define.calls.argsFor(0);
const defineArgs = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineArgs[0]).toBe('element-a-selector');
// Verify the right component was loaded/created
@ -80,6 +84,30 @@ fdescribe('ElementsLoader', () => {
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy();
}));
it('should be able to register multiple elements', fakeAsync(() => {
// Verify that the elements loader considered `element-a-selector` to be unregistered.
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeTruthy();
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
elementsLoader.loadContainingCustomElements(hostEl);
tick();
const defineElementA = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineElementA[0]).toBe('element-a-selector');
expect(defineElementA[1].observedAttributes[0]).toBe('element-a-input');
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy();
const defineElementB = fakeCustomElementsDefine.calls.argsFor(1);
expect(defineElementB[0]).toBe('element-b-selector');
expect(defineElementB[1].observedAttributes[0]).toBe('element-b-input');
expect(elementsLoader.elementsToLoad.has('element-b-selector')).toBeFalsy();
}));
it('should only register an element one time', fakeAsync(() => {
const hostEl = document.createElement('div');
hostEl.innerHTML = `<element-a-selector></element-a-selector>`;

View File

@ -8,14 +8,14 @@ import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.ser
import { Logger } from 'app/shared/logger.service';
import { CustomElementsModule } from 'app/custom-elements/custom-elements.module';
import { TocService } from 'app/shared/toc.service';
import { ElementsLoader } from 'app/custom-elements/elements-loader';
import {
MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent
MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader
} from 'testing/doc-viewer-utils';
import { MockLogger } from 'testing/logger.service';
import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component';
describe('DocViewerComponent', () => {
let parentFixture: ComponentFixture<TestParentComponent>;
let parentComponent: TestParentComponent;
@ -24,7 +24,7 @@ describe('DocViewerComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestModule, CustomElementsModule],
imports: [CustomElementsModule, TestModule],
});
parentFixture = TestBed.createComponent(TestParentComponent);
@ -294,12 +294,16 @@ describe('DocViewerComponent', () => {
describe('#render()', () => {
let prepareTitleAndTocSpy: jasmine.Spy;
let swapViewsSpy: jasmine.Spy;
let loadElementsSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') =>
new Promise<void>((resolve, reject) =>
docViewer.render({contents, id}).subscribe(resolve, reject));
beforeEach(() => {
const elementsLoader = TestBed.get(ElementsLoader) as MockElementsLoader;
loadElementsSpy =
elementsLoader.loadContainingCustomElements.and.returnValue(of([]));
prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc');
swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
});
@ -333,7 +337,7 @@ describe('DocViewerComponent', () => {
expect(docViewerEl.textContent).toBe('');
});
it('should prepare the title and ToC', async () => {
it('should prepare the title and ToC (before embedding components)', async () => {
prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => {
expect(targetEl.innerHTML).toBe('Some content');
expect(docId).toBe('foo');
@ -342,6 +346,7 @@ describe('DocViewerComponent', () => {
await doRender('Some content', 'foo');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy);
});
it('should set the title and ToC (after the content has been set)', async () => {
@ -384,6 +389,39 @@ describe('DocViewerComponent', () => {
});
});
describe('(embedding components)', () => {
it('should embed components', async () => {
await doRender('Some content');
expect(loadElementsSpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledWith(docViewer.nextViewContainer);
});
it('should attempt to embed components even if the document is empty', async () => {
await doRender('');
await doRender(null);
expect(loadElementsSpy).toHaveBeenCalledTimes(2);
expect(loadElementsSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]);
expect(loadElementsSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]);
});
it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => {
const obs = new ObservableWithSubscriptionSpies();
loadElementsSpy.and.returnValue(obs);
const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'});
const subscription = renderObservable.subscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
subscription.unsubscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
});
});
describe('(swapping views)', () => {
it('should still swap the views if the document is empty', async () => {
await doRender('');
@ -444,6 +482,25 @@ describe('DocViewerComponent', () => {
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' });
});
it('when `EmbedComponentsService.embedInto()` fails', async () => {
const error = Error('Typical `embedInto()` error');
loadElementsSpy.and.callFake(() => {
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
throw error;
});
await doRender('Some content', 'bar');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
[`[DocViewer] Error preparing document 'bar': ${error.stack}`],
]);
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'googlebot', content: 'noindex' });
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' });
});
it('when `swapViews()` fails', async () => {
const error = Error('Typical `swapViews()` error');
@ -486,13 +543,24 @@ describe('DocViewerComponent', () => {
});
describe('(events)', () => {
it('should emit `docReady`', async () => {
it('should emit `docReady` after loading elements', async () => {
const onDocReadySpy = jasmine.createSpy('onDocReady');
docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content');
expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledBefore(onDocReadySpy);
});
it('should emit `docReady` before swapping views', async () => {
const onDocReadySpy = jasmine.createSpy('onDocReady');
docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content');
expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy);
});
it('should emit `docRendered` after swapping views', async () => {

View File

@ -67,12 +67,12 @@ export class DocViewerComponent implements OnDestroy {
@Output() docRendered = new EventEmitter<void>();
constructor(
elementRef: ElementRef,
private logger: Logger,
private titleService: Title,
private metaService: Meta,
private tocService: TocService,
private elementsLoader: ElementsLoader) {
elementRef: ElementRef,
private logger: Logger,
private titleService: Title,
private metaService: Meta,
private tocService: TocService,
private elementsLoader: ElementsLoader) {
this.hostElement = elementRef.nativeElement;
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
this.hostElement.innerHTML = initialDocViewerContent;

View File

@ -110,6 +110,15 @@
}
</script>
<script>
// Custom elements should always rely on the polyfill to avoid having to include a shim that
// handles downleveled ES2015 classes. Especially since that shim would break on IE11 which
// can't even parse such code.
if (window.customElements) {
window.customElements['forcePolyfill'] = true;
}
</script>
</head>
<body>

View File

@ -33,9 +33,6 @@ import './environments/environment';
/** Add support for window.customElements */
import '@webcomponents/custom-elements/custom-elements.min';
/** Required for custom elements for apps building to es5. */
import '@webcomponents/custom-elements/src/native-shim';
/** ALL Firefox browsers require the following to support `@angular/animation`. **/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.

View File

@ -8,6 +8,7 @@ import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { Logger } from 'app/shared/logger.service';
import { TocService } from 'app/shared/toc.service';
import { MockLogger } from 'testing/logger.service';
import { ElementsLoader } from 'app/custom-elements/elements-loader';
////////////////////////////////////////////////////////////////////////////////////////////////////
@ -54,6 +55,11 @@ export class MockTocService {
reset = jasmine.createSpy('TocService#reset');
}
export class MockElementsLoader {
loadContainingCustomElements =
jasmine.createSpy('MockElementsLoader#loadContainingCustomElements');
}
@NgModule({
declarations: [
DocViewerComponent,
@ -64,6 +70,7 @@ export class MockTocService {
{ provide: Title, useClass: MockTitle },
{ provide: Meta, useClass: MockMeta },
{ provide: TocService, useClass: MockTocService },
{ provide: ElementsLoader, useClass: MockElementsLoader },
],
})
export class TestModule { }