[APM-419] Adding data input to dynamic component (#4084)

* Extract ComponentRegisterService from ExtensionService

* Make DynamicExtensionComponent smarter

* Add component existence safeguards

* hasComponentById

* Fixing spell check for the sake of mankind

* Add more proper tests and remove double ngOnDestroy call

* Remove lifecycle patches
This commit is contained in:
Popovics András
2018-12-17 13:43:04 +01:00
committed by GitHub
parent ffd72b853f
commit 074225970d
6 changed files with 256 additions and 20 deletions

View File

@@ -0,0 +1,155 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* tslint:disable:component-selector */
import {
Component,
Input,
SimpleChanges,
OnChanges,
SimpleChange,
ComponentFactoryResolver
} from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
import { DynamicExtensionComponent } from './dynamic.component';
import { ComponentRegisterService } from '../../services/component-register.service';
import { HttpClientModule } from '@angular/common/http';
@Component({
selector: 'test-component',
template: '<div data-automation-id="found-me">Hey I am the mighty test component!</div>'
})
export class TestComponent implements OnChanges {
@Input() data: any;
public onChangesCalled = 0;
ngOnChanges(changes: SimpleChanges) { this.onChangesCalled ++; }
}
describe('DynamicExtensionComponent', () => {
let fixture: ComponentFixture<DynamicExtensionComponent>;
let componentRegister: ComponentRegisterService;
let component: DynamicExtensionComponent;
let componentFactoryResolver: ComponentFactoryResolver;
beforeEach(async(() => {
componentRegister = new ComponentRegisterService();
componentRegister.setComponents({'test-component': TestComponent});
TestBed.configureTestingModule({
imports: [ HttpClientModule ],
declarations: [ DynamicExtensionComponent, TestComponent ],
providers: [ { provide: ComponentRegisterService, useValue: componentRegister } ]
});
TestBed.overrideModule(BrowserDynamicTestingModule, {
set: { entryComponents: [ TestComponent ] }
});
TestBed.compileComponents();
}));
describe('Sub-component creation', () => {
beforeEach(() => {
fixture = TestBed.createComponent(DynamicExtensionComponent);
componentFactoryResolver = TestBed.get(ComponentFactoryResolver);
spyOn(componentFactoryResolver, 'resolveComponentFactory').and.callThrough();
component = fixture.componentInstance;
component.id = 'test-component';
component.data = { foo: 'bar' };
fixture.detectChanges();
component.ngOnChanges({});
});
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
it('should load the TestComponent', () => {
const innerElement = fixture.debugElement.query(By.css('[data-automation-id="found-me"]'));
expect(innerElement).not.toBeNull();
});
it('should load the TestComponent only ONCE', () => {
component.ngOnChanges({});
fixture.detectChanges();
component.ngOnChanges({});
fixture.detectChanges();
expect((<any> componentFactoryResolver.resolveComponentFactory).calls.count()).toBe(1);
});
it('should pass through the data', () => {
const testComponent = fixture.debugElement.query(By.css('test-component')).componentInstance;
expect(testComponent.data).toBe(component.data);
});
it('should update the subcomponent\'s input parameters', () => {
const data = { foo: 'baz' };
component.ngOnChanges({ data: new SimpleChange(component.data, data, false) });
const testComponent = fixture.debugElement.query(By.css('test-component')).componentInstance;
expect(testComponent.data).toBe(data);
});
});
describe('Angular life-cycle methods in sub-component', () => {
let testComponent;
beforeEach(() => {
fixture = TestBed.createComponent(DynamicExtensionComponent);
component = fixture.componentInstance;
component.id = 'test-component';
fixture.detectChanges();
component.ngOnChanges({});
testComponent = fixture.debugElement.query(By.css('test-component')).componentInstance;
});
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
it('should call through the ngOnChanges', () => {
const params = {};
component.ngOnChanges(params);
expect(testComponent.onChangesCalled).toBe(2);
});
it('should NOT call through the ngOnChanges if the method does not exist (no error should be thrown)', () => {
testComponent.ngOnChanges = undefined;
const params = {};
const execution = () => {
component.ngOnChanges(params);
};
expect(execution).not.toThrowError();
});
});
});

View File

@@ -19,33 +19,49 @@ import {
Component, Component,
Input, Input,
ComponentRef, ComponentRef,
OnInit,
ComponentFactoryResolver, ComponentFactoryResolver,
ViewChild, ViewChild,
ViewContainerRef, ViewContainerRef,
OnDestroy OnDestroy,
OnChanges,
SimpleChanges
} from '@angular/core'; } from '@angular/core';
import { ExtensionService } from '../../services/extension.service'; import { ExtensionService } from '../../services/extension.service';
import { ExtensionComponent } from '../../services/component-register.service';
// cSpell:words lifecycle
@Component({ @Component({
selector: 'adf-dynamic-component', selector: 'adf-dynamic-component',
template: `<div #content></div>` template: `<div #content></div>`
}) })
export class DynamicExtensionComponent implements OnInit, OnDestroy { export class DynamicExtensionComponent implements OnChanges, OnDestroy {
@ViewChild('content', { read: ViewContainerRef }) @ViewChild('content', { read: ViewContainerRef })
content: ViewContainerRef; content: ViewContainerRef;
@Input() id: string; @Input() id: string;
@Input() data: any;
private componentRef: ComponentRef<any>; private componentRef: ComponentRef<ExtensionComponent>;
private loaded: boolean = false;
constructor( constructor(private extensions: ExtensionService, private componentFactoryResolver: ComponentFactoryResolver) {}
private extensions: ExtensionService,
private componentFactoryResolver: ComponentFactoryResolver
) {}
ngOnInit() { ngOnChanges(changes: SimpleChanges) {
const componentType = this.extensions.getComponentById(this.id); if (!this.loaded) {
this.loadComponent();
this.loaded = true;
}
if (changes.data) {
this.data = changes.data.currentValue;
}
this.updateInstance();
this.proxy('ngOnChanges', changes);
}
private loadComponent() {
const componentType = this.extensions.getComponentById<ExtensionComponent>(this.id);
if (componentType) { if (componentType) {
const factory = this.componentFactoryResolver.resolveComponentFactory( const factory = this.componentFactoryResolver.resolveComponentFactory(
componentType componentType
@@ -53,15 +69,34 @@ export class DynamicExtensionComponent implements OnInit, OnDestroy {
if (factory) { if (factory) {
this.content.clear(); this.content.clear();
this.componentRef = this.content.createComponent(factory, 0); this.componentRef = this.content.createComponent(factory, 0);
// this.setupWidget(this.componentRef);
} }
} }
} }
ngOnDestroy() { ngOnDestroy() {
if (this.componentRef) { if (this.componentCreated()) {
this.componentRef.destroy(); this.componentRef.destroy();
this.componentRef = null; this.componentRef = null;
} }
} }
private updateInstance() {
if (this.componentCreated()) {
this.componentRef.instance.data = this.data;
}
}
private proxy(lifecycleMethod, ...args) {
if (this.componentCreated() && this.lifecycleHookIsImplemented(lifecycleMethod)) {
this.componentRef.instance[lifecycleMethod].apply(this.componentRef.instance, args);
}
}
private componentCreated(): boolean {
return !!this.componentRef && !!this.componentRef.instance;
}
private lifecycleHookIsImplemented(lifecycleMethod: string): boolean {
return !!this.componentRef.instance[lifecycleMethod];
}
} }

View File

@@ -0,0 +1,41 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, Type } from '@angular/core';
export interface ExtensionComponent {
data: any;
}
@Injectable({ providedIn: 'root' })
export class ComponentRegisterService {
components: { [key: string]: Type<{}> } = {};
setComponents(values: { [key: string]: Type<{}> }) {
if (values) {
this.components = Object.assign({}, this.components, values);
}
}
getComponentById<T>(id: string): Type<T> {
return <Type<T>> this.components[id];
}
hasComponentById(id: string): boolean {
return !!this.getComponentById(id);
}
}

View File

@@ -21,6 +21,7 @@ import { ExtensionConfig } from '../config/extension.config';
import { RuleRef } from '../config/rule.extensions'; import { RuleRef } from '../config/rule.extensions';
import { RouteRef } from '../config/routing.extensions'; import { RouteRef } from '../config/routing.extensions';
import { ActionRef } from '../config/action.extensions'; import { ActionRef } from '../config/action.extensions';
import { ComponentRegisterService } from './component-register.service';
describe('ExtensionService', () => { describe('ExtensionService', () => {
const blankConfig: ExtensionConfig = { const blankConfig: ExtensionConfig = {
@@ -33,11 +34,13 @@ describe('ExtensionService', () => {
}; };
let loader: ExtensionLoaderService; let loader: ExtensionLoaderService;
let componentRegister: ComponentRegisterService;
let service: ExtensionService; let service: ExtensionService;
beforeEach(() => { beforeEach(() => {
loader = new ExtensionLoaderService(null); loader = new ExtensionLoaderService(null);
service = new ExtensionService(loader); componentRegister = new ComponentRegisterService();
service = new ExtensionService(loader, componentRegister);
}); });
it('should load and setup a config', async () => { it('should load and setup a config', async () => {

View File

@@ -22,6 +22,7 @@ import { ExtensionLoaderService } from './extension-loader.service';
import { RouteRef } from '../config/routing.extensions'; import { RouteRef } from '../config/routing.extensions';
import { ActionRef } from '../config/action.extensions'; import { ActionRef } from '../config/action.extensions';
import * as core from '../evaluators/core.evaluators'; import * as core from '../evaluators/core.evaluators';
import { ComponentRegisterService } from './component-register.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -35,10 +36,12 @@ export class ExtensionService {
actions: Array<ActionRef> = []; actions: Array<ActionRef> = [];
authGuards: { [key: string]: Type<{}> } = {}; authGuards: { [key: string]: Type<{}> } = {};
components: { [key: string]: Type<{}> } = {};
evaluators: { [key: string]: RuleEvaluator } = {}; evaluators: { [key: string]: RuleEvaluator } = {};
constructor(private loader: ExtensionLoaderService) {} constructor(
private loader: ExtensionLoaderService,
private componentRegister: ComponentRegisterService
) {}
async load(): Promise<ExtensionConfig> { async load(): Promise<ExtensionConfig> {
const config = await this.loader.load( const config = await this.loader.load(
@@ -79,9 +82,7 @@ export class ExtensionService {
} }
setComponents(values: { [key: string]: Type<{}> }) { setComponents(values: { [key: string]: Type<{}> }) {
if (values) { this.componentRegister.setComponents(values);
this.components = Object.assign({}, this.components, values);
}
} }
getRouteById(id: string): RouteRef { getRouteById(id: string): RouteRef {
@@ -125,8 +126,8 @@ export class ExtensionService {
return false; return false;
} }
getComponentById(id: string): Type<{}> { getComponentById<T>(id: string) {
return this.components[id]; return this.componentRegister.getComponentById<T>(id);
} }
getRuleById(id: string): RuleRef { getRuleById(id: string): RuleRef {

View File

@@ -28,6 +28,7 @@ export * from './lib/config/viewer.extensions';
export * from './lib/services/extension-loader.service'; export * from './lib/services/extension-loader.service';
export * from './lib/services/extension.service'; export * from './lib/services/extension.service';
export * from './lib/services/component-register.service';
export * from './lib/store/states/navigation.state'; export * from './lib/store/states/navigation.state';
export * from './lib/store/states/profile.state'; export * from './lib/store/states/profile.state';