mirror of
https://github.com/storybookjs/storybook.git
synced 2025-03-31 05:03:21 +08:00
feat: add a rewrite of client angular rendering
A `useLegacyRendering` flag allows to use old rendering engine
This commit is contained in:
parent
460fc4d33e
commit
21701da1a3
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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);
|
||||
};
|
32
app/angular/src/client/preview/angular-beta/app.token.ts
Normal file
32
app/angular/src/client/preview/angular-beta/app.token.ts
Normal 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();
|
||||
};
|
||||
});
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user