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 1a84862
Show file tree
Hide file tree
Showing 9 changed files with 397 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;
};
134 changes: 134 additions & 0 deletions app/angular/src/client/preview/angular/RenderNgAppService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/* eslint-disable no-undef */
import { NgModule, NO_ERRORS_SCHEMA, Type } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { StoryFn } from '@storybook/addons';

import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs';
import { ICollection, StoryFnAngularReturnType } from '../types';
import { storyPropsProvider } from './app.token';
import { createComponentClassFromStoryComponent } from './ComponentClassFromStoryComponent';
import { createComponentClassFromStoryTemplate } from './ComponentClassFromStoryTemplate';
import { getExistenceOfComponentInModules } from './NgModulesAnalyzer';

/**
* Bootstrap angular application and allows to change the rendering dynamically
* To be used as a singleton so has to set global properties of render function
*/
export class RenderNgAppService {
private static instance: RenderNgAppService;

public static getInstance() {
if (RenderNgAppService.instance) {
RenderNgAppService.instance = new RenderNgAppService();
}
return RenderNgAppService.instance;
}

public static SELECTOR_STORYBOOK_WRAPPER = 'storybook-wrapper';

private platform = platformBrowserDynamic();

private staticRoot = document.getElementById('root');

// Observable to change the properties dynamically without reloading angular module&component
private storyProps$: Subject<ICollection | undefined>;

constructor() {
// Adds DOM element that angular will use as bootstrap component
const storybookWrapperElement = document.createElement(
RenderNgAppService.SELECTOR_STORYBOOK_WRAPPER
);
this.staticRoot.innerHTML = '';
this.staticRoot.appendChild(storybookWrapperElement);
}

/**
* Bootstrap main angular module with main component or send only new `props` with storyProps$
*
* @param storyFn {StoryFn<StoryFnAngularReturnType>}
* @param forced {boolean} If :
* - true render will only use the StoryFn `props' in storyProps observable that will update sotry's component/template properties. Improves performance without reloading the whole module&component if props changes
* - false fully recharges or initializes angular module & component
*/
public async render(storyFn: StoryFn<StoryFnAngularReturnType>, forced: boolean) {
const storyObj = storyFn();

if (forced && this.storyProps$) {
this.storyProps$.next(storyObj.props);
return;
}

// Complete last BehaviorSubject and create a new one for the current module
if (this.storyProps$) {
this.storyProps$.complete();
}
this.storyProps$ = new BehaviorSubject<ICollection>(storyObj.props);

await this.platform.bootstrapModule(
createModuleFromMetadata(getNgModuleMetadata(storyObj, this.storyProps$))
);
}
}

const getNgModuleMetadata = (
storyFnAngular: StoryFnAngularReturnType,
storyProps$: Subject<ICollection>
): NgModule => {
const { component, moduleMetadata = {} } = storyFnAngular;

const ComponentToInject = createComponentToInject(storyFnAngular);

// Look recursively (deep) if the component is not already declared by an import module
const requiresComponentDeclaration =
component &&
!getExistenceOfComponentInModules(
component,
moduleMetadata.declarations,
moduleMetadata.imports
);

return {
declarations: [
...(requiresComponentDeclaration ? [component] : []),
ComponentToInject,
...(moduleMetadata.declarations ?? []),
],
imports: [BrowserModule, ...(moduleMetadata.imports ?? [])],
providers: [storyPropsProvider(storyProps$), ...(moduleMetadata.providers ?? [])],
entryComponents: [...(moduleMetadata.entryComponents ?? [])],
schemas: [
// Required because`props` don't only contain Inputs/Outputs which generates a bad template for component wrapper
// This happens with the addon Controls + Doc
// The private and public properties of the component are also added in `props` (wrongly)
// FIXME : To be removed when the above behavior is corrected
NO_ERRORS_SCHEMA,
...(moduleMetadata.schemas ?? []),
],
bootstrap: [ComponentToInject],
};
};

const createModuleFromMetadata = (ngModule: NgModule) => {
@NgModule(ngModule)
class StoryBookAppModule {}
return StoryBookAppModule;
};

/**
* Create a specific component according to whether the story uses a template or a component.
*/
const createComponentToInject = ({
template,
styles,
component,
props,
}: StoryFnAngularReturnType): Type<any> => {
// Template has priority over the component
const isCreatingComponentFromTemplate = !!template;

return isCreatingComponentFromTemplate
? createComponentClassFromStoryTemplate(template, styles)
: createComponentClassFromStoryComponent(component, props);
};

0 comments on commit 1a84862

Please sign in to comment.