Skip to content

Commit

Permalink
feat: rewrite rendering feature of angular client
Browse files Browse the repository at this point in the history
TODO: add description
  • Loading branch information
ThibaudAV committed Nov 23, 2020
1 parent 9c5192e commit 9ba4837
Show file tree
Hide file tree
Showing 9 changed files with 408 additions and 344 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnDestroy,
Type,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { Subscription, Subject } from 'rxjs';
import { map, skip } from 'rxjs/operators';

import { ICollection } from '../types';
import { STORY_PROPS } from './app.token';
import { RenderNgAppService } from './RenderNgAppService';

const findComponentDecoratorMetadata = (component: any) => {
const decoratorKey = '__annotations__';
const decorators: any[] =
Reflect &&
Reflect.getOwnPropertyDescriptor &&
Reflect.getOwnPropertyDescriptor(component, decoratorKey)
? Reflect.getOwnPropertyDescriptor(component, decoratorKey).value
: (component as any)[decoratorKey];

const ngComponentDecorator: Component | undefined = decorators.find(
(decorator) => decorator instanceof Component
);

return ngComponentDecorator;
};

const toInputsOutputs = (props: ICollection = {}) => {
return Object.entries(props).reduce(
(previousValue, [key, value]) => {
if (typeof value === 'function') {
return { ...previousValue, outputs: { ...previousValue.outputs, [key]: value } };
}

return { ...previousValue, inputs: { ...previousValue.inputs, [key]: value } };
},
{ inputs: {}, outputs: {} } as { inputs: Record<string, any>; outputs: Record<string, any> }
);
};

/**
* Wraps the story component into a component
*
* @param component
* @param initialProps
*/
export const createComponentClassFromStoryComponent = (
component: any,
initialProps?: ICollection
): Type<any> => {
const ngComponentMetadata = findComponentDecoratorMetadata(component);

const { inputs: initialInputs, outputs: initialOutputs } = toInputsOutputs(initialProps);

const templateInputs = Object.keys(initialInputs)
.map((i) => `[${i}]="${i}"`)
.join(' ');
const templateOutputs = Object.keys(initialOutputs)
.map((i) => `(${i})="${i}($event)"`)
.join(' ');

@Component({
selector: RenderNgAppService.SELECTOR_STORYBOOK_WRAPPER,
// Simulates the `component` integration in a template
// `props` are converted into Inputs/Outputs to be added directly in the template so as the component can use them during its initailization
// - The outputs are connected only once here
// - Only inputs present in initial `props` value are added. They will be overwritten and completed as necessary after the component is initialized
template: `<${ngComponentMetadata.selector} ${templateInputs} ${templateOutputs} #storyComponentRef></${ngComponentMetadata.selector}>`,
})
class StoryBookComponentWrapperComponent implements AfterViewInit, OnDestroy {
private storyPropsSubscription: Subscription;

@ViewChild('storyComponentRef', { static: true }) storyComponentElementRef: ElementRef;

@ViewChild('storyComponentRef', { read: ViewContainerRef, static: true })
storyComponentViewContainerRef: ViewContainerRef;

constructor(
@Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>,
private changeDetectorRef: ChangeDetectorRef
) {
// Initializes template Inputs/Outputs values
Object.assign(this, initialProps);
}

ngAfterViewInit(): void {
// Once target component has been initialized, the storyProps$ observable keeps target component inputs up to date
this.storyPropsSubscription = this.storyProps$
.pipe(skip(1), map(toInputsOutputs))
.subscribe(({ inputs }) => {
// Replace inputs with new ones from props
Object.assign(this.storyComponentElementRef, inputs);

// `markForCheck` the component in case this uses changeDetection: OnPush
// And then forces the `detectChanges`
this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck();
this.changeDetectorRef.detectChanges();
});
}

ngOnDestroy(): void {
if (this.storyPropsSubscription != null) {
this.storyPropsSubscription.unsubscribe();
}
}
}
return StoryBookComponentWrapperComponent;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Inject, ChangeDetectorRef, Component, OnDestroy, OnInit, Type } from '@angular/core';
import { Subject, Subscription } from 'rxjs';

import { ICollection } from '../types';
import { STORY_PROPS } from './app.token';
import { RenderNgAppService } from './RenderNgAppService';

/**
* Wraps the story template into a component
*
* @param template {string}
* @param styles {string[]}
*/
export const createComponentClassFromStoryTemplate = (
template: string,
styles: string[]
): Type<any> => {
@Component({
selector: RenderNgAppService.SELECTOR_STORYBOOK_WRAPPER,
template,
styles,
})
class StoryBookTemplateWrapperComponent implements OnInit, OnDestroy {
private storyPropsSubscription: Subscription;

// eslint-disable-next-line no-useless-constructor
constructor(
@Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>,
private changeDetectorRef: ChangeDetectorRef
) {}

ngOnInit(): void {
// Subscribes to the observable storyProps$ to keep these properties up to date
this.storyPropsSubscription = this.storyProps$.subscribe((storyProps) => {
if (!storyProps) {
// All props are added as component properties
Object.assign(this, storyProps);
}

this.changeDetectorRef.detectChanges();
this.changeDetectorRef.markForCheck();
});
}

ngOnDestroy(): void {
if (this.storyPropsSubscription != null) {
this.storyPropsSubscription.unsubscribe();
}
}
}
return StoryBookTemplateWrapperComponent;
};
26 changes: 26 additions & 0 deletions app/angular/src/client/preview/angular/NgModulesAnalyzer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Component, NgModule } from '@angular/core';
import { getExistenceOfComponentInModules } from './NgModulesAnalyzer';

const FooComponent = Component({})(class {});

const BarComponent = Component({})(class {});

const BetaModule = NgModule({ declarations: [FooComponent] })(class {});

const AlphaModule = NgModule({ imports: [BetaModule] })(class {});

describe('getExistenceOfComponentInModules', () => {
it('should return true when the component is already declared in one of modules', () => {
expect(getExistenceOfComponentInModules(FooComponent, [], [AlphaModule])).toEqual(true);
});

it('should return true if the component is in moduleDeclarations', () => {
expect(getExistenceOfComponentInModules(BarComponent, [BarComponent], [AlphaModule])).toEqual(
true
);
});

it('should return false if the component is not declared', () => {
expect(getExistenceOfComponentInModules(BarComponent, [], [AlphaModule])).toEqual(false);
});
});
59 changes: 59 additions & 0 deletions app/angular/src/client/preview/angular/NgModulesAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NgModule } from '@angular/core';

/**
* Avoid component redeclaration
*
* Checks recursively if the component has already been declared in all import Module
*/
export const getExistenceOfComponentInModules = (
componentToFind: any,
moduleDeclarations: any[],
moduleImports: any[]
): boolean => {
if (
moduleDeclarations &&
moduleDeclarations.some((declaration) => declaration === componentToFind)
) {
// Found component in declarations array
return true;
}
if (!moduleImports) {
return false;
}

return moduleImports.some((importItem) => {
const extractedNgModuleMetadata = extractNgModuleMetadata(importItem);
if (!extractedNgModuleMetadata) {
// Not an NgModule
return false;
}
return getExistenceOfComponentInModules(
componentToFind,
extractedNgModuleMetadata.declarations,
extractedNgModuleMetadata.imports
);
});
};

const extractNgModuleMetadata = (importItem: any): NgModule => {
const target = importItem && importItem.ngModule ? importItem.ngModule : importItem;
const decoratorKey = '__annotations__';
const decorators: any[] =
Reflect &&
Reflect.getOwnPropertyDescriptor &&
Reflect.getOwnPropertyDescriptor(target, decoratorKey)
? Reflect.getOwnPropertyDescriptor(target, decoratorKey).value
: target[decoratorKey];

if (!decorators || decorators.length === 0) {
return null;
}

const ngModuleDecorator: NgModule | undefined = decorators.find(
(decorator) => decorator instanceof NgModule
);
if (!ngModuleDecorator) {
return null;
}
return ngModuleDecorator;
};

0 comments on commit 9ba4837

Please sign in to comment.