feat: add a rewrite of client angular rendering

A `useLegacyRendering` flag allows to use old rendering engine
This commit is contained in:
ThibaudAv 2020-12-02 23:57:11 +01:00 committed by Gert Hengeveld
parent 460fc4d33e
commit 21701da1a3
8 changed files with 435 additions and 3 deletions

View File

@ -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;
};

View File

@ -0,0 +1,50 @@
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 = {}) => {
// 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;
};

View File

@ -0,0 +1,26 @@
import { Component, NgModule } from '@angular/core';
import { isComponentAlreadyDeclaredInModules } from './NgModulesAnalyzer';
const FooComponent = Component({})(class {});
const BarComponent = Component({})(class {});
const BetaModule = NgModule({ declarations: [FooComponent] })(class {});
const AlphaModule = NgModule({ imports: [BetaModule] })(class {});
describe('isComponentAlreadyDeclaredInModules', () => {
it('should return true when the component is already declared in one of modules', () => {
expect(isComponentAlreadyDeclaredInModules(FooComponent, [], [AlphaModule])).toEqual(true);
});
it('should return true if the component is in moduleDeclarations', () => {
expect(
isComponentAlreadyDeclaredInModules(BarComponent, [BarComponent], [AlphaModule])
).toEqual(true);
});
it('should return false if the component is not declared', () => {
expect(isComponentAlreadyDeclaredInModules(BarComponent, [], [AlphaModule])).toEqual(false);
});
});

View File

@ -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 isComponentAlreadyDeclaredInModules = (
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 isComponentAlreadyDeclaredInModules(
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;
};

View File

@ -0,0 +1,129 @@
/* eslint-disable no-undef */
import { NgModule, Type } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { StoryFn } from '@storybook/addons';
import { BehaviorSubject, Subject } from 'rxjs';
import { ICollection, StoryFnAngularReturnType } from '../types';
import { storyPropsProvider } from './app.token';
import { createComponentClassFromStoryComponent } from './ComponentClassFromStoryComponent';
import { createComponentClassFromStoryTemplate } from './ComponentClassFromStoryTemplate';
import { isComponentAlreadyDeclaredInModules } 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);
this.platform = platformBrowserDynamic();
}
/**
* 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(this.getNgModuleMetadata(storyObj, this.storyProps$))
);
}
public 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 &&
!isComponentAlreadyDeclaredInModules(
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: [...(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);
};

View File

@ -0,0 +1,32 @@
import { InjectionToken, NgZone, Provider } from '@angular/core';
import { Observable, Subject, Subscriber } from 'rxjs';
import { ICollection } from '../types';
export const STORY_PROPS = new InjectionToken<Subject<ICollection | undefined>>('STORY_PROPS');
export const storyPropsProvider = (storyProps$: Subject<ICollection | undefined>): Provider => ({
provide: STORY_PROPS,
useFactory: storyDataFactory(storyProps$.asObservable()),
deps: [NgZone],
});
function storyDataFactory<T>(data: Observable<T>) {
return (ngZone: NgZone) =>
new Observable((subscriber: Subscriber<T>) => {
const sub = data.subscribe(
(v: T) => {
ngZone.run(() => subscriber.next(v));
},
(err) => {
ngZone.run(() => subscriber.error(err));
},
() => {
ngZone.run(() => subscriber.complete());
}
);
return () => {
sub.unsubscribe();
};
});
}

View File

@ -1,18 +1,28 @@
import { StoryFn } from '@storybook/addons';
import { RenderNgAppService } from './angular-beta/RenderNgAppService';
import { renderNgApp } from './angular/helpers';
import { StoryFnAngularReturnType } from './types';
import { Parameters } from './types-6-0';
// add proper types
export default function render({
storyFn,
showMain,
forceRender,
parameters,
}: {
storyFn: StoryFn<StoryFnAngularReturnType>;
showMain: () => void;
forceRender: boolean;
parameters: Parameters;
}) {
showMain();
renderNgApp(storyFn, forceRender);
if (parameters.angularLegacyRendering) {
renderNgApp(storyFn, forceRender);
return;
}
RenderNgAppService.getInstance().render(storyFn, forceRender);
}

View File

@ -1,7 +1,13 @@
import { Args as DefaultArgs, Annotations, BaseMeta, BaseStory } from '@storybook/addons';
import {
Args as DefaultArgs,
Annotations,
BaseMeta,
BaseStory,
Parameters as DefaultParameters,
} from '@storybook/addons';
import { StoryFnAngularReturnType } from './types';
export { Args, ArgTypes, Parameters, StoryContext } from '@storybook/addons';
export { Args, ArgTypes, StoryContext } from '@storybook/addons';
type AngularComponent = any;
type AngularReturnType = StoryFnAngularReturnType;
@ -21,3 +27,8 @@ export type Meta<Args = DefaultArgs> = BaseMeta<AngularComponent> &
*/
export type Story<Args = DefaultArgs> = BaseStory<Args, AngularReturnType> &
Annotations<Args, AngularReturnType>;
export type Parameters = DefaultParameters & {
/** Uses legacy angular rendering engine that use dynamic component */
angularLegacyRendering?: boolean;
};