diff --git a/app/angular/src/client/preview/angular/NgComponentAnalyzer.test.ts b/app/angular/src/client/preview/angular/NgComponentAnalyzer.test.ts new file mode 100644 index 000000000000..3eaf27a3fb11 --- /dev/null +++ b/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(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + 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(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + 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>(component: T): ComponentFactory { + TestBed.configureTestingModule({ + declarations: [component], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [component], + }, + }); + const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver); + + return componentFactoryResolver.resolveComponentFactory(component); +} diff --git a/app/angular/src/client/preview/angular/NgComponentAnalyzer.ts b/app/angular/src/client/preview/angular/NgComponentAnalyzer.ts new file mode 100644 index 000000000000..3666c8a6150d --- /dev/null +++ b/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 = + 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); +};