Skip to content

Commit

Permalink
feat: add function to find Inputs & Outputs in an Angular component
Browse files Browse the repository at this point in the history
The test allows to verify the function by using angular engine
  • Loading branch information
ThibaudAV committed Nov 26, 2020
1 parent 0c6326d commit 0491336
Show file tree
Hide file tree
Showing 2 changed files with 222 additions and 0 deletions.
116 changes: 116 additions & 0 deletions app/angular/src/client/preview/angular/NgComponentAnalyzer.test.ts
@@ -0,0 +1,116 @@
import {
Component,
ComponentFactory,
ComponentFactoryResolver,
EventEmitter,
Input,
Output,
Type,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';

import { getComponentInputsOutputs } from './NgComponentAnalyzer';

describe('getComponentInputsOutputs', () => {
it('should return empty if no I/O found', () => {
@Component({})
class FooComponent {}

expect(getComponentInputsOutputs(FooComponent)).toEqual({
inputs: [],
outputs: [],
});
});

it('should return I/O', () => {
@Component({
template: '',
inputs: ['inputInComponentMetadata'],
outputs: ['outputInComponentMetadata'],
})
class FooComponent {
@Input()
public input: string;

@Input('inputPropertyName')
public inputWithBindingPropertyName: string;

@Output()
public output = new EventEmitter<Event>();

@Output('outputPropertyName')
public outputWithBindingPropertyName = new EventEmitter<Event>();
}

const fooComponentFactory = resolveComponentFactory(FooComponent);

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

expect({ inputs, outputs }).toEqual({
inputs: [
{ propName: 'inputInComponentMetadata', templateName: 'inputInComponentMetadata' },
{ propName: 'input', templateName: 'input' },
{ propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' },
],
outputs: [
{ propName: 'outputInComponentMetadata', templateName: 'outputInComponentMetadata' },
{ propName: 'output', templateName: 'output' },
{ propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' },
],
});

expect(sortByPropName(inputs)).toEqual(sortByPropName(fooComponentFactory.inputs));
expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs));
});

it("should return I/O when some of component metadata has the same name as one of component's properties", () => {
@Component({
template: '',
inputs: ['input', 'inputWithBindingPropertyName'],
outputs: ['outputWithBindingPropertyName'],
})
class FooComponent {
@Input()
public input: string;

@Input('inputPropertyName')
public inputWithBindingPropertyName: string;

@Output()
public output = new EventEmitter<Event>();

@Output('outputPropertyName')
public outputWithBindingPropertyName = new EventEmitter<Event>();
}

const fooComponentFactory = resolveComponentFactory(FooComponent);

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

expect(sortByPropName(inputs)).toEqual(sortByPropName(fooComponentFactory.inputs));
expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs));
});
});

function sortByPropName(
array: {
propName: string;
templateName: string;
}[]
) {
return array.sort((a, b) => a.propName.localeCompare(b.propName));
}

function resolveComponentFactory<T extends Type<any>>(component: T): ComponentFactory<T> {
TestBed.configureTestingModule({
declarations: [component],
}).overrideModule(BrowserDynamicTestingModule, {
set: {
entryComponents: [component],
},
});
const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver);

return componentFactoryResolver.resolveComponentFactory(component);
}
106 changes: 106 additions & 0 deletions app/angular/src/client/preview/angular/NgComponentAnalyzer.ts
@@ -0,0 +1,106 @@
import { Component, Input, Output } from '@angular/core';

export type ComponentInputsOutputs = {
inputs: {
propName: string;
templateName: string;
}[];
outputs: {
propName: string;
templateName: string;
}[];
};

/**
* Returns component Inputs / Outputs by browsing these properties and decorator
*/
export const getComponentInputsOutputs = (component: any): ComponentInputsOutputs => {
const componentMetadata = getComponentDecoratorMetadata(component);
const componentPropsMetadata = getComponentPropsDecoratorMetadata(component);

const initialValue: ComponentInputsOutputs = {
inputs: [],
outputs: [],
};

// Adds the I/O present in @Component metadata
if (componentMetadata && componentMetadata.inputs) {
initialValue.inputs.push(
...componentMetadata.inputs.map((i) => ({ propName: i, templateName: i }))
);
}
if (componentMetadata && componentMetadata.outputs) {
initialValue.outputs.push(
...componentMetadata.outputs.map((i) => ({ propName: i, templateName: i }))
);
}

if (!componentPropsMetadata) {
return initialValue;
}

// Browses component properties to extract I/O
// Filters properties that have the same name as the one present in the @Component property
return Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, [value]]) => {
if (value instanceof Input) {
const inputToAdd = {
propName: propertyName,
templateName: value.bindingPropertyName ?? propertyName,
};

const previousInputsFiltered = previousValue.inputs.filter(
(i) => i.templateName !== propertyName
);
return {
...previousValue,
inputs: [...previousInputsFiltered, inputToAdd],
};
}
if (value instanceof Output) {
const outputToAdd = {
propName: propertyName,
templateName: value.bindingPropertyName ?? propertyName,
};

const previousOutputsFiltered = previousValue.outputs.filter(
(i) => i.templateName !== propertyName
);
return {
...previousValue,
outputs: [...previousOutputsFiltered, outputToAdd],
};
}
return previousValue;
}, initialValue);
};

/**
* Returns all component decorator properties
* is used to get all `@Input` and `@Output` Decorator
*/
export const getComponentPropsDecoratorMetadata = (component: any) => {
const decoratorKey = '__prop__metadata__';
const propsDecorators: Record<string, (Input | Output)[]> =
Reflect &&
Reflect.getOwnPropertyDescriptor &&
Reflect.getOwnPropertyDescriptor(component, decoratorKey)
? Reflect.getOwnPropertyDescriptor(component, decoratorKey).value
: component[decoratorKey];

return propsDecorators || undefined;
};

/**
* Returns component decorator `@Component`
*/
export const getComponentDecoratorMetadata = (component: any): Component | undefined => {
const decoratorKey = '__annotations__';
const decorators: any[] =
Reflect &&
Reflect.getOwnPropertyDescriptor &&
Reflect.getOwnPropertyDescriptor(component, decoratorKey)
? Reflect.getOwnPropertyDescriptor(component, decoratorKey).value
: component[decoratorKey];

return (decorators || []).find((d) => d instanceof Component);
};

0 comments on commit 0491336

Please sign in to comment.