parent
46efd4b938
commit
87f60bccfd
|
@ -4,7 +4,7 @@
|
|||
"uncompressed": {
|
||||
"inline": 2062,
|
||||
"main": 467103,
|
||||
"polyfills": 55349,
|
||||
"polyfills": 54292,
|
||||
"embedded": 71711,
|
||||
"prettify": 14888
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -34,8 +34,6 @@ import { CodeComponent } from './code.component';
|
|||
export class CodeExampleComponent implements AfterViewInit {
|
||||
classes: {};
|
||||
|
||||
code: string;
|
||||
|
||||
@Input() language: string;
|
||||
|
||||
@Input() linenums: string;
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
||||
|
|
|
@ -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 { }
|
||||
|
|
|
@ -205,6 +205,13 @@ function extractProperties(
|
|||
function extractName(type: Function): string|null {
|
||||
let name = type['name'];
|
||||
|
||||
// The polyfill @webcomponents/custom-element/src/native-shim.js overrides the
|
||||
// window.HTMLElement and does not have the name property. Check if this is the
|
||||
// case and if so, set the name manually.
|
||||
if (name === '' && type === HTMLElement) {
|
||||
name = 'HTMLElement';
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
// see https://www.w3.org/TR/html5/index.html
|
||||
// TODO(vicb): generate this map from all the element types
|
||||
|
|
|
@ -6,29 +6,35 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {ApplicationRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges} from '@angular/core';
|
||||
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {merge} from 'rxjs/observable/merge';
|
||||
import {map} from 'rxjs/operator/map';
|
||||
|
||||
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
|
||||
import {extractProjectableNodes} from './extract-projectable-nodes';
|
||||
import {camelToDashCase, isFunction, scheduler, strictEquals} from './utils';
|
||||
import {isFunction, scheduler, strictEquals} from './utils';
|
||||
|
||||
/** Time in milliseconds to wait before destroying the component ref when disconnected. */
|
||||
const DESTROY_DELAY = 10;
|
||||
|
||||
/**
|
||||
* Factory that creates new ComponentFactoryNgElementStrategy instances with the strategy factory's
|
||||
* injector. A new strategy instance is created with the provided component factory which will
|
||||
* create its components on connect.
|
||||
* Factory that creates new ComponentNgElementStrategy instance. Gets the component factory with the
|
||||
* constructor's injector's factory resolver and passes that factory to each strategy.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class ComponentFactoryNgElementStrategyFactory implements NgElementStrategyFactory {
|
||||
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
|
||||
export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
|
||||
componentFactory: ComponentFactory<any>;
|
||||
|
||||
create() { return new ComponentFactoryNgElementStrategy(this.componentFactory, this.injector); }
|
||||
constructor(private component: Type<any>, private injector: Injector) {
|
||||
this.componentFactory =
|
||||
injector.get(ComponentFactoryResolver).resolveComponentFactory(component);
|
||||
}
|
||||
|
||||
create(injector: Injector) {
|
||||
return new ComponentNgElementStrategy(this.componentFactory, injector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -37,12 +43,12 @@ export class ComponentFactoryNgElementStrategyFactory implements NgElementStrate
|
|||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
|
||||
export class ComponentNgElementStrategy implements NgElementStrategy {
|
||||
/** Merged stream of the component's output events. */
|
||||
events: Observable<NgElementStrategyEvent>;
|
||||
|
||||
/** Reference to the component that was created on connect. */
|
||||
private componentRef: ComponentRef<any>;
|
||||
private componentRef: ComponentRef<any>|null;
|
||||
|
||||
/** Changes that have been made to the component ref since the last time onChanges was called. */
|
||||
private inputChanges: SimpleChanges|null = null;
|
||||
|
@ -96,6 +102,7 @@ export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
|
|||
this.scheduledDestroyFn = scheduler.schedule(() => {
|
||||
if (this.componentRef) {
|
||||
this.componentRef !.destroy();
|
||||
this.componentRef = null;
|
||||
}
|
||||
}, DESTROY_DELAY);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ComponentFactory} from '@angular/core';
|
||||
import {ComponentFactory, Injector} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
|
||||
/**
|
||||
|
@ -38,4 +38,7 @@ export interface NgElementStrategy {
|
|||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface NgElementStrategyFactory { create(): NgElementStrategy; }
|
||||
export interface NgElementStrategyFactory {
|
||||
/** Creates a new instance to be used for an NgElement. */
|
||||
create(injector: Injector): NgElementStrategy;
|
||||
}
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
|
||||
import {Injector, Type} from '@angular/core';
|
||||
import {Subscription} from 'rxjs/Subscription';
|
||||
|
||||
import {ComponentFactoryNgElementStrategyFactory} from './component-factory-strategy';
|
||||
import {ComponentNgElementStrategyFactory} from './component-factory-strategy';
|
||||
import {NgElementStrategy, NgElementStrategyFactory} from './element-strategy';
|
||||
import {camelToDashCase, createCustomEvent} from './utils';
|
||||
import {createCustomEvent, getComponentInputs, getDefaultAttributeToPropertyInputs} from './utils';
|
||||
|
||||
/**
|
||||
* Class constructor based on an Angular Component to be used for custom element registration.
|
||||
|
@ -21,7 +21,7 @@ import {camelToDashCase, createCustomEvent} from './utils';
|
|||
export interface NgElementConstructor<P> {
|
||||
readonly observedAttributes: string[];
|
||||
|
||||
new (): NgElement&WithProperties<P>;
|
||||
new (injector: Injector): NgElement&WithProperties<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -50,30 +50,19 @@ export type WithProperties<P> = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Initialization configuration for the NgElementConstructor. Provides the strategy factory
|
||||
* that produces a strategy for each instantiated element. Additionally, provides a function
|
||||
* that takes the component factory and provides a map of which attributes should be observed on
|
||||
* the element and which property they are associated with.
|
||||
* Initialization configuration for the NgElementConstructor which contains the injector to be used
|
||||
* for retrieving the component's factory as well as the default context for the component. May
|
||||
* provide a custom strategy factory to be used instead of the default. May provide a custom mapping
|
||||
* of attribute names to component inputs.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface NgElementConfig {
|
||||
injector: Injector;
|
||||
strategyFactory?: NgElementStrategyFactory;
|
||||
propertyInputs?: string[];
|
||||
attributeToPropertyInputs?: {[key: string]: string};
|
||||
}
|
||||
|
||||
/** Gets a map of default set of attributes to observe and the properties they affect. */
|
||||
function getDefaultAttributeToPropertyInputs(inputs: {propName: string, templateName: string}[]) {
|
||||
const attributeToPropertyInputs: {[key: string]: string} = {};
|
||||
inputs.forEach(({propName, templateName}) => {
|
||||
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
|
||||
});
|
||||
|
||||
return attributeToPropertyInputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @whatItDoes Creates a custom element class based on an Angular Component. Takes a configuration
|
||||
* that provides initialization information to the created class. E.g. the configuration's injector
|
||||
|
@ -90,13 +79,10 @@ function getDefaultAttributeToPropertyInputs(inputs: {propName: string, template
|
|||
*/
|
||||
export function createNgElementConstructor<P>(
|
||||
component: Type<any>, config: NgElementConfig): NgElementConstructor<P> {
|
||||
const componentFactoryResolver =
|
||||
config.injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
|
||||
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
|
||||
const inputs = componentFactory.inputs;
|
||||
const inputs = getComponentInputs(component, config.injector);
|
||||
|
||||
const defaultStrategyFactory = config.strategyFactory ||
|
||||
new ComponentFactoryNgElementStrategyFactory(componentFactory, config.injector);
|
||||
const strategyFactory =
|
||||
config.strategyFactory || new ComponentNgElementStrategyFactory(component, config.injector);
|
||||
|
||||
const attributeToPropertyInputs =
|
||||
config.attributeToPropertyInputs || getDefaultAttributeToPropertyInputs(inputs);
|
||||
|
@ -104,13 +90,9 @@ export function createNgElementConstructor<P>(
|
|||
class NgElementImpl extends NgElement {
|
||||
static readonly observedAttributes = Object.keys(attributeToPropertyInputs);
|
||||
|
||||
constructor(strategyFactoryOverride?: NgElementStrategyFactory) {
|
||||
constructor(injector?: Injector) {
|
||||
super();
|
||||
|
||||
// Use the constructor's strategy factory override if it is present, otherwise default to
|
||||
// the config's factory.
|
||||
const strategyFactory = strategyFactoryOverride || defaultStrategyFactory;
|
||||
this.ngElementStrategy = strategyFactory.create();
|
||||
this.ngElementStrategy = strategyFactory.create(injector || config.injector);
|
||||
}
|
||||
|
||||
attributeChangedCallback(
|
||||
|
@ -120,14 +102,6 @@ export function createNgElementConstructor<P>(
|
|||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
// Take element attribute inputs and set them as inputs on the strategy
|
||||
NgElementImpl.observedAttributes.forEach(attrName => {
|
||||
const propName = attributeToPropertyInputs[attrName] !;
|
||||
if (this.hasAttribute(attrName)) {
|
||||
this.ngElementStrategy.setInputValue(propName, this.getAttribute(attrName) !);
|
||||
}
|
||||
});
|
||||
|
||||
this.ngElementStrategy.connect(this);
|
||||
|
||||
// Listen for events from the strategy and dispatch them as custom events
|
||||
|
@ -149,8 +123,7 @@ export function createNgElementConstructor<P>(
|
|||
|
||||
// Add getters and setters to the prototype for each property input. If the config does not
|
||||
// contain property inputs, use all inputs by default.
|
||||
const propertyInputs = config.propertyInputs || inputs.map(({propName}) => propName);
|
||||
propertyInputs.forEach(property => {
|
||||
inputs.map(({propName}) => propName).forEach(property => {
|
||||
Object.defineProperty(NgElementImpl.prototype, property, {
|
||||
get: function() { return this.ngElementStrategy.getInputValue(property); },
|
||||
set: function(newValue: any) { this.ngElementStrategy.setInputValue(property, newValue); },
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
import {Type} from '@angular/core';
|
||||
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
|
||||
|
||||
const elProto = Element.prototype as any;
|
||||
const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector ||
|
||||
|
@ -101,3 +100,25 @@ export function matchesSelector(element: Element, selector: string): boolean {
|
|||
export function strictEquals(value1: any, value2: any): boolean {
|
||||
return value1 === value2 || (value1 !== value1 && value2 !== value2);
|
||||
}
|
||||
|
||||
/** Gets a map of default set of attributes to observe and the properties they affect. */
|
||||
export function getDefaultAttributeToPropertyInputs(
|
||||
inputs: {propName: string, templateName: string}[]) {
|
||||
const attributeToPropertyInputs: {[key: string]: string} = {};
|
||||
inputs.forEach(({propName, templateName}) => {
|
||||
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
|
||||
});
|
||||
|
||||
return attributeToPropertyInputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a component's set of inputs. Uses the injector to get the component factory where the inputs
|
||||
* are defined.
|
||||
*/
|
||||
export function getComponentInputs(
|
||||
component: Type<any>, injector: Injector): {propName: string, templateName: string}[] {
|
||||
const componentFactoryResolver: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
|
||||
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
|
||||
return componentFactory.inputs;
|
||||
}
|
||||
|
|
|
@ -10,12 +10,12 @@ import {ComponentFactory, ComponentRef, Injector, NgModuleRef, SimpleChange, Sim
|
|||
import {fakeAsync, tick} from '@angular/core/testing';
|
||||
import {Subject} from 'rxjs/Subject';
|
||||
|
||||
import {ComponentFactoryNgElementStrategy, ComponentFactoryNgElementStrategyFactory} from '../src/component-factory-strategy';
|
||||
import {ComponentNgElementStrategy, ComponentNgElementStrategyFactory} from '../src/component-factory-strategy';
|
||||
import {NgElementStrategyEvent} from '../src/element-strategy';
|
||||
|
||||
describe('ComponentFactoryNgElementStrategy', () => {
|
||||
let factory: FakeComponentFactory;
|
||||
let strategy: ComponentFactoryNgElementStrategy;
|
||||
let strategy: ComponentNgElementStrategy;
|
||||
|
||||
let injector: any;
|
||||
let componentRef: any;
|
||||
|
@ -25,22 +25,26 @@ describe('ComponentFactoryNgElementStrategy', () => {
|
|||
factory = new FakeComponentFactory();
|
||||
componentRef = factory.componentRef;
|
||||
|
||||
injector = jasmine.createSpyObj('injector', ['get']);
|
||||
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
|
||||
injector.get.and.returnValue(applicationRef);
|
||||
|
||||
strategy = new ComponentFactoryNgElementStrategy(factory, injector);
|
||||
strategy = new ComponentNgElementStrategy(factory, injector);
|
||||
});
|
||||
|
||||
it('should create a new strategy from the factory', () => {
|
||||
const strategyFactory = new ComponentFactoryNgElementStrategyFactory(factory, injector);
|
||||
expect(strategyFactory.create()).toBeTruthy();
|
||||
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
|
||||
factoryResolver.resolveComponentFactory.and.returnValue(factory);
|
||||
injector = jasmine.createSpyObj('injector', ['get']);
|
||||
injector.get.and.returnValue(factoryResolver);
|
||||
|
||||
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
|
||||
expect(strategyFactory.create(injector)).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('after connected', () => {
|
||||
beforeEach(() => {
|
||||
// Set up an initial value to make sure it is passed to the component
|
||||
strategy.setInputValue('fooFoo', 'fooFoo-1');
|
||||
injector.get.and.returnValue(applicationRef);
|
||||
strategy.connect(document.createElement('div'));
|
||||
});
|
||||
|
||||
|
@ -95,7 +99,7 @@ describe('ComponentFactoryNgElementStrategy', () => {
|
|||
it('should not detect changes', fakeAsync(() => {
|
||||
strategy.setInputValue('fooFoo', 'fooFoo-1');
|
||||
tick(16); // scheduler waits 16ms if RAF is unavailable
|
||||
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(0);
|
||||
expect(componentRef.changeDetectorRef.detectChanges).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@ if (typeof customElements !== 'undefined') {
|
|||
});
|
||||
|
||||
it('should send input values from attributes when connected', () => {
|
||||
const element = new NgElementCtor();
|
||||
const element = new NgElementCtor(injector);
|
||||
element.setAttribute('foo-foo', 'value-foo-foo');
|
||||
element.setAttribute('barbar', 'value-barbar');
|
||||
element.connectedCallback();
|
||||
|
@ -62,7 +62,7 @@ if (typeof customElements !== 'undefined') {
|
|||
});
|
||||
|
||||
it('should listen to output events after connected', () => {
|
||||
const element = new NgElementCtor();
|
||||
const element = new NgElementCtor(injector);
|
||||
element.connectedCallback();
|
||||
|
||||
let eventValue: any = null;
|
||||
|
@ -73,7 +73,7 @@ if (typeof customElements !== 'undefined') {
|
|||
});
|
||||
|
||||
it('should not listen to output events after disconnected', () => {
|
||||
const element = new NgElementCtor();
|
||||
const element = new NgElementCtor(injector);
|
||||
element.connectedCallback();
|
||||
element.disconnectedCallback();
|
||||
expect(strategy.disconnectCalled).toBe(true);
|
||||
|
@ -86,7 +86,7 @@ if (typeof customElements !== 'undefined') {
|
|||
});
|
||||
|
||||
it('should properly set getters/setters on the element', () => {
|
||||
const element = new NgElementCtor();
|
||||
const element = new NgElementCtor(injector);
|
||||
element.fooFoo = 'foo-foo-value';
|
||||
element.barBar = 'barBar-value';
|
||||
|
||||
|
@ -104,29 +104,26 @@ if (typeof customElements !== 'undefined') {
|
|||
NgElementCtorWithChangedAttr = createNgElementConstructor(TestComponent, {
|
||||
injector,
|
||||
strategyFactory,
|
||||
propertyInputs: ['prop1', 'prop2'],
|
||||
attributeToPropertyInputs: {'attr-1': 'prop1', 'attr-2': 'prop2'}
|
||||
attributeToPropertyInputs: {'attr-1': 'fooFoo', 'attr-2': 'barbar'}
|
||||
});
|
||||
|
||||
customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr);
|
||||
});
|
||||
|
||||
beforeEach(() => { element = new NgElementCtorWithChangedAttr(); });
|
||||
beforeEach(() => { element = new NgElementCtorWithChangedAttr(injector); });
|
||||
|
||||
it('should affect which attributes are watched', () => {
|
||||
expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']);
|
||||
});
|
||||
|
||||
it('should send attribute values as inputs when connected', () => {
|
||||
const element = new NgElementCtorWithChangedAttr();
|
||||
const element = new NgElementCtorWithChangedAttr(injector);
|
||||
element.setAttribute('attr-1', 'value-1');
|
||||
element.setAttribute('attr-2', 'value-2');
|
||||
element.setAttribute('attr-3', 'value-3'); // Made-up attribute
|
||||
element.connectedCallback();
|
||||
|
||||
expect(strategy.getInputValue('prop1')).toBe('value-1');
|
||||
expect(strategy.getInputValue('prop2')).toBe('value-2');
|
||||
expect(strategy.getInputValue('prop3')).not.toBe('value-3');
|
||||
expect(strategy.getInputValue('fooFoo')).toBe('value-1');
|
||||
expect(strategy.getInputValue('barbar')).toBe('value-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -56,7 +56,7 @@ if [[ ${TRAVIS} &&
|
|||
travisFoldStart "yarn-install.aio"
|
||||
(
|
||||
# HACK (don't submit with this): Build Angular
|
||||
./build.sh
|
||||
./build.sh --packages=core,elements --examples=false
|
||||
|
||||
cd ${PROJECT_ROOT}/aio
|
||||
yarn install --frozen-lockfile --non-interactive
|
||||
|
|
84
test-main.js
84
test-main.js
|
@ -76,7 +76,7 @@ Promise
|
|||
.resolve()
|
||||
|
||||
// Load browser-specific polyfills for custom elements.
|
||||
// .then(function() { return loadCustomElementsPolyfills(); })
|
||||
.then(function() { return loadCustomElementsPolyfills(); })
|
||||
|
||||
// Load necessary testing packages.
|
||||
.then(function() {
|
||||
|
@ -151,29 +151,35 @@ function loadCustomElementsPolyfills() {
|
|||
Object.setPrototypeOf = setPrototypeOf;
|
||||
}
|
||||
|
||||
// The custom elements polyfill will patch `(HTML)Element` properties, including `innerHTML`:
|
||||
// https://github.com/webcomponents/custom-elements/blob/32f043c3a5e5fc3e035342c0ef10c6786fa416d7/src/Patch/Element.js#L28-L78
|
||||
// The patched `innerHTML` setter will try to traverse the DOM (via `nextSibling`), which leads to
|
||||
// infinite loops when testing `HtmlSanitizer` with cloberred elements on browsers that do not
|
||||
// support the `<template>` element:
|
||||
// The custom elements polyfill will patch properties and methods on `(HTML)Element` and `Node`
|
||||
// (among others), including `(HTML)Element#innerHTML` and `Node#removeChild()`:
|
||||
// https://github.com/webcomponents/custom-elements/blob/4f7072c0dbda4beb505d16967acfffd33337b325/src/Patch/Element.js#L28-L73
|
||||
// https://github.com/webcomponents/custom-elements/blob/4f7072c0dbda4beb505d16967acfffd33337b325/src/Patch/Node.js#L105-L120
|
||||
// The patched `innerHTML` setter and `removeChild()` method will try to traverse the DOM (via
|
||||
// `nextSibling` and `parentNode` respectively), which leads to infinite loops when testing
|
||||
// `HtmlSanitizer` with cloberred elements on browsers that do not support the `<template>`
|
||||
// element:
|
||||
// https://github.com/angular/angular/blob/213baa37b0b71e72d00ad7b606ebfc2ade06b934/packages/platform-browser/src/security/html_sanitizer.ts#L29-L38
|
||||
// To avoid that, we "unpatch" `(HTML)Element#innerHTML` and apply the patch only for the relevant
|
||||
// To avoid that, we "unpatch" these properties/methods and apply the patch only for the relevant
|
||||
// `@angular/elements` tests.
|
||||
var patchTarget;
|
||||
var originalDescriptor;
|
||||
var patchConfig = {'innerHTML': ['Element', 'HTMLElement'], 'removeChild': ['Node']};
|
||||
var patchTargets = {};
|
||||
var originalDescriptors = {};
|
||||
if (!window.customElements) {
|
||||
['Element', 'HTMLElement']
|
||||
.map(function(name) { return window[name].prototype; })
|
||||
.some(function(candidatePatchTarget) {
|
||||
var candidateOriginalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(candidatePatchTarget, 'innerHTML');
|
||||
Object.keys(patchConfig).forEach(function(prop) {
|
||||
patchConfig[prop]
|
||||
.map(function(name) { return window[name].prototype; })
|
||||
.some(function(candidatePatchTarget) {
|
||||
var candidateOriginalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(candidatePatchTarget, prop);
|
||||
|
||||
if (candidateOriginalDescriptor) {
|
||||
patchTarget = candidatePatchTarget;
|
||||
originalDescriptor = candidateOriginalDescriptor;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (candidateOriginalDescriptor) {
|
||||
patchTargets[prop] = candidatePatchTarget;
|
||||
originalDescriptors[prop] = candidateOriginalDescriptor;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var polyfillPath = !window.customElements ?
|
||||
|
@ -188,23 +194,31 @@ function loadCustomElementsPolyfills() {
|
|||
// but custom element polyfills will replace `HTMLElement` with an anonymous function.
|
||||
Object.defineProperty(HTMLElement, 'name', {value: 'HTMLElement'});
|
||||
|
||||
// Create helper functions on `window` for patching/restoring `(HTML)Element#innerHTML`.
|
||||
if (!patchTarget) {
|
||||
window.$$patchInnerHtmlProp = window.$$restoreInnerHtmlProp = function() {};
|
||||
} else {
|
||||
var patchedDescriptor = Object.getOwnPropertyDescriptor(patchTarget, 'innerHTML');
|
||||
// Create helper functions on `window` for patching/restoring properties/methods.
|
||||
Object.keys(patchConfig).forEach(function(prop) {
|
||||
var patchMethod = '$$patch_' + prop;
|
||||
var restoreMethod = '$$restore_' + prop;
|
||||
|
||||
window.$$patchInnerHtmlProp = function() {
|
||||
Object.defineProperty(patchTarget, 'innerHTML', patchedDescriptor);
|
||||
};
|
||||
window.$$restoreInnerHtmlProp = function() {
|
||||
Object.defineProperty(patchTarget, 'innerHTML', originalDescriptor);
|
||||
};
|
||||
if (!patchTargets[prop]) {
|
||||
// No patching detected. Create no-op functions.
|
||||
window[patchMethod] = window[restoreMethod] = function() {};
|
||||
} else {
|
||||
var patchTarget = patchTargets[prop];
|
||||
var originalDescriptor = originalDescriptors[prop];
|
||||
var patchedDescriptor = Object.getOwnPropertyDescriptor(patchTarget, prop);
|
||||
|
||||
// Restore `innerHTML`. The patch will be manually applied only during the
|
||||
// `@angular/elements` tests that need it.
|
||||
window.$$restoreInnerHtmlProp();
|
||||
}
|
||||
window[patchMethod] = function() {
|
||||
Object.defineProperty(patchTarget, prop, patchedDescriptor);
|
||||
};
|
||||
window[restoreMethod] = function() {
|
||||
Object.defineProperty(patchTarget, prop, originalDescriptor);
|
||||
};
|
||||
|
||||
// Restore `prop`. The patch will be manually applied only during the
|
||||
// `@angular/elements` tests that need it.
|
||||
window[restoreMethod]();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return loadedPromise;
|
||||
|
|
|
@ -16,14 +16,13 @@ export interface NgElementConfig {
|
|||
[key: string]: string;
|
||||
};
|
||||
injector: Injector;
|
||||
propertyInputs?: string[];
|
||||
strategyFactory?: NgElementStrategyFactory;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export interface NgElementConstructor<P> {
|
||||
readonly observedAttributes: string[];
|
||||
new (): NgElement & WithProperties<P>;
|
||||
new (injector: Injector): NgElement & WithProperties<P>;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
|
@ -43,7 +42,7 @@ export interface NgElementStrategyEvent {
|
|||
|
||||
/** @experimental */
|
||||
export interface NgElementStrategyFactory {
|
||||
create(): NgElementStrategy;
|
||||
create(injector: Injector): NgElementStrategy;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
|
|
Loading…
Reference in New Issue