-
-
Notifications
You must be signed in to change notification settings - Fork 9.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: rewrite rendering feature of angular client
TODO: add description
- Loading branch information
Showing
9 changed files
with
406 additions
and
344 deletions.
There are no files selected for viewing
115 changes: 115 additions & 0 deletions
115
app/angular/src/client/preview/angular/ComponentClassFromStoryComponent.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
50 changes: 50 additions & 0 deletions
50
app/angular/src/client/preview/angular/ComponentClassFromStoryTemplate.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
26 changes: 26 additions & 0 deletions
26
app/angular/src/client/preview/angular/NgModulesAnalyzer.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
59
app/angular/src/client/preview/angular/NgModulesAnalyzer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
145 changes: 145 additions & 0 deletions
145
app/angular/src/client/preview/angular/RenderNgAppService.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
/* 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); | ||
|
||
if (typeof NODE_ENV === 'string' && NODE_ENV !== 'development') { | ||
try { | ||
enableProdMode(); | ||
} catch (e) { | ||
// eslint-disable-next-line no-console | ||
console.error(e); | ||
} | ||
} | ||
// platform should be set after enableProdMode() | ||
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(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); | ||
}; |
Oops, something went wrong.