diff --git a/MIGRATION.md b/MIGRATION.md index 62179a505182..ab90c0450655 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,7 @@

Migration

+- [From version 6.1.x to 6.2.0](#from-version-61x-to-620) + - [New Angular renderer](#new-angular-renderer) - [From version 6.0.x to 6.1.0](#from-version-60x-to-610) - [Single story hoisting](#single-story-hoisting) - [React peer dependencies](#react-peer-dependencies) @@ -138,6 +140,20 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) +## From version 6.1.x to 6.2.0 + +### New Angular renderer + +We've rewritten the Angular renderer in Storybook 6.1. It's meant to be entirely backwards compatible, but if you need to use the legacy renderer it's still available via a [parameter](https://storybook.js.org/docs/react/writing-stories/parameters). To opt out of the new renderer, add the following to `.storybook/preview.ts`: + +```ts +export const parameters = { + angularLegacyRendering: true, +}; +``` + +Please also file an issue if you need to opt out. We plan to remove the legacy renderer in 7.0. + ## From version 6.0.x to 6.1.0 ### Single story hoisting diff --git a/app/angular/jest.config.js b/app/angular/jest.config.js new file mode 100644 index 000000000000..d1edfe27c2d1 --- /dev/null +++ b/app/angular/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'jest-preset-angular', + setupFilesAfterEnv: ['/setup-jest.ts'], +}; diff --git a/app/angular/package.json b/app/angular/package.json index a4189ed08db5..3ec112ea8594 100644 --- a/app/angular/package.json +++ b/app/angular/package.json @@ -55,7 +55,7 @@ "react-dom": "16.13.1", "regenerator-runtime": "^0.13.7", "sass-loader": "^8.0.0", - "strip-json-comments": "^3.0.1", + "strip-json-comments": "3.1.1", "ts-loader": "^6.0.1", "tsconfig-paths-webpack-plugin": "^3.2.0", "webpack": "^4.44.2" @@ -70,7 +70,11 @@ "@angular/forms": "^11.0.0", "@angular/platform-browser": "^11.0.0", "@angular/platform-browser-dynamic": "^11.0.0", - "@types/autoprefixer": "^9.4.0" + "@types/autoprefixer": "^9.4.0", + "@types/jest": "^25.1.1", + "jest": "^26.0.0", + "jest-preset-angular": "^8.3.2", + "ts-jest": "^26.4.4" }, "peerDependencies": { "@angular-devkit/build-angular": ">=0.8.9", diff --git a/app/angular/setup-jest.ts b/app/angular/setup-jest.ts new file mode 100644 index 000000000000..a910afad9002 --- /dev/null +++ b/app/angular/setup-jest.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import 'jest-preset-angular'; diff --git a/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryComponent.ts b/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryComponent.ts new file mode 100644 index 000000000000..edd8793a050e --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryComponent.ts @@ -0,0 +1,146 @@ +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 { + ComponentInputsOutputs, + getComponentDecoratorMetadata, + getComponentInputsOutputs, +} from './NgComponentAnalyzer'; +import { RenderNgAppService } from './RenderNgAppService'; + +const getNamesOfInputsOutputsDefinedInProps = ( + ngComponentInputsOutputs: ComponentInputsOutputs, + props: ICollection = {} +) => { + const inputs = ngComponentInputsOutputs.inputs + .filter((i) => i.templateName in props) + .map((i) => i.templateName); + const outputs = ngComponentInputsOutputs.outputs + .filter((o) => o.templateName in props) + .map((o) => o.templateName); + return { + inputs, + outputs, + otherProps: Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k)), + }; +}; + +/** + * Wraps the story component into a component + * + * @param component + * @param initialProps + */ +export const createComponentClassFromStoryComponent = ( + component: any, + initialProps?: ICollection +): Type => { + const ngComponentMetadata = getComponentDecoratorMetadata(component); + const ngComponentInputsOutputs = getComponentInputsOutputs(component); + + const { + inputs: initialInputs, + outputs: initialOutputs, + otherProps: initialOtherProps, + } = getNamesOfInputsOutputsDefinedInProps(ngComponentInputsOutputs, initialProps); + + const templateInputs = initialInputs.map((i) => `[${i}]="${i}"`).join(' '); + const templateOutputs = 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>`, + }) + 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, + private changeDetectorRef: ChangeDetectorRef + ) { + // Initializes template Inputs/Outputs values + Object.assign(this, initialProps); + } + + ngAfterViewInit(): void { + // Initializes properties that are not Inputs | Outputs + // Allows story props to override local component properties + initialOtherProps.forEach((p) => { + (this.storyComponentElementRef as any)[p] = initialProps[p]; + }); + + // `markForCheck` the component in case this uses changeDetection: OnPush + // And then forces the `detectChanges` + this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck(); + this.changeDetectorRef.detectChanges(); + + // Once target component has been initialized, the storyProps$ observable keeps target component inputs up to date + this.storyPropsSubscription = this.storyProps$ + .pipe( + skip(1), + map((props) => { + // removes component output in props + const outputsKeyToRemove = ngComponentInputsOutputs.outputs.map((o) => o.templateName); + return Object.entries(props).reduce( + (prev, [key, value]) => ({ + ...prev, + ...(!outputsKeyToRemove.includes(key) && { [key]: value }), + }), + {} as ICollection + ); + }), + map((props) => { + // In case a component uses an input with `bindingPropertyName` (ex: @Input('name')) + // find the value of the local propName in the component Inputs + // otherwise use the input key + return Object.entries(props).reduce((prev, [propKey, value]) => { + const input = ngComponentInputsOutputs.inputs.find((o) => o.templateName === propKey); + + return { + ...prev, + ...(input ? { [input.propName]: value } : { [propKey]: value }), + }; + }, {} as ICollection); + }) + ) + .subscribe((props) => { + // Replace inputs with new ones from props + Object.assign(this.storyComponentElementRef, props); + + // `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; +}; diff --git a/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryTemplate.ts b/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryTemplate.ts new file mode 100644 index 000000000000..d95fbe101d01 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/ComponentClassFromStoryTemplate.ts @@ -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 => { + @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, + 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; +}; diff --git a/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.test.ts b/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.test.ts new file mode 100644 index 000000000000..7645a89e8af8 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.test.ts @@ -0,0 +1,123 @@ +import { + Component, + ComponentFactory, + ComponentFactoryResolver, + EventEmitter, + Input, + Output, + Type, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; + +import { getComponentInputsOutputs } from './NgComponentAnalyzer'; + +describe('getComponentInputsOutputs', () => { + it('should return empty if no I/O found', () => { + @Component({}) + class FooComponent {} + + expect(getComponentInputsOutputs(FooComponent)).toEqual({ + inputs: [], + outputs: [], + }); + + class BarComponent {} + + expect(getComponentInputsOutputs(BarComponent)).toEqual({ + inputs: [], + outputs: [], + }); + }); + + it('should return I/O', () => { + @Component({ + template: '', + inputs: ['inputInComponentMetadata'], + outputs: ['outputInComponentMetadata'], + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'inputInComponentMetadata', templateName: 'inputInComponentMetadata' }, + { propName: 'input', templateName: 'input' }, + { propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' }, + ], + outputs: [ + { propName: 'outputInComponentMetadata', templateName: 'outputInComponentMetadata' }, + { propName: 'output', templateName: 'output' }, + { propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' }, + ], + }); + + expect(sortByPropName(inputs)).toEqual(sortByPropName(fooComponentFactory.inputs)); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it("should return I/O when some of component metadata has the same name as one of component's properties", () => { + @Component({ + template: '', + inputs: ['input', 'inputWithBindingPropertyName'], + outputs: ['outputWithBindingPropertyName'], + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(sortByPropName(inputs)).toEqual(sortByPropName(fooComponentFactory.inputs)); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); +}); + +function sortByPropName( + array: { + propName: string; + templateName: string; + }[] +) { + return array.sort((a, b) => a.propName.localeCompare(b.propName)); +} + +function resolveComponentFactory>(component: T): ComponentFactory { + TestBed.configureTestingModule({ + declarations: [component], + }).overrideModule(BrowserDynamicTestingModule, { + set: { + entryComponents: [component], + }, + }); + const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver); + + return componentFactoryResolver.resolveComponentFactory(component); +} diff --git a/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.ts b/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.ts new file mode 100644 index 000000000000..f3daafca9f43 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/NgComponentAnalyzer.ts @@ -0,0 +1,106 @@ +import { Component, Input, Output } from '@angular/core'; + +export type ComponentInputsOutputs = { + inputs: { + propName: string; + templateName: string; + }[]; + outputs: { + propName: string; + templateName: string; + }[]; +}; + +/** + * Returns component Inputs / Outputs by browsing these properties and decorator + */ +export const getComponentInputsOutputs = (component: any): ComponentInputsOutputs => { + const componentMetadata = getComponentDecoratorMetadata(component); + const componentPropsMetadata = getComponentPropsDecoratorMetadata(component); + + const initialValue: ComponentInputsOutputs = { + inputs: [], + outputs: [], + }; + + // Adds the I/O present in @Component metadata + if (componentMetadata && componentMetadata.inputs) { + initialValue.inputs.push( + ...componentMetadata.inputs.map((i) => ({ propName: i, templateName: i })) + ); + } + if (componentMetadata && componentMetadata.outputs) { + initialValue.outputs.push( + ...componentMetadata.outputs.map((i) => ({ propName: i, templateName: i })) + ); + } + + if (!componentPropsMetadata) { + return initialValue; + } + + // Browses component properties to extract I/O + // Filters properties that have the same name as the one present in the @Component property + return Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, [value]]) => { + if (value instanceof Input) { + const inputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? propertyName, + }; + + const previousInputsFiltered = previousValue.inputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + inputs: [...previousInputsFiltered, inputToAdd], + }; + } + if (value instanceof Output) { + const outputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? propertyName, + }; + + const previousOutputsFiltered = previousValue.outputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + outputs: [...previousOutputsFiltered, outputToAdd], + }; + } + return previousValue; + }, initialValue); +}; + +/** + * Returns all component decorator properties + * is used to get all `@Input` and `@Output` Decorator + */ +export const getComponentPropsDecoratorMetadata = (component: any) => { + const decoratorKey = '__prop__metadata__'; + const propsDecorators: Record = + Reflect && + Reflect.getOwnPropertyDescriptor && + Reflect.getOwnPropertyDescriptor(component, decoratorKey) + ? Reflect.getOwnPropertyDescriptor(component, decoratorKey).value + : component[decoratorKey]; + + return propsDecorators; +}; + +/** + * Returns component decorator `@Component` + */ +export const getComponentDecoratorMetadata = (component: any): Component | undefined => { + const decoratorKey = '__annotations__'; + const decorators: any[] = + Reflect && + Reflect.getOwnPropertyDescriptor && + Reflect.getOwnPropertyDescriptor(component, decoratorKey) + ? Reflect.getOwnPropertyDescriptor(component, decoratorKey).value + : component[decoratorKey]; + + return (decorators || []).find((d) => d instanceof Component); +}; diff --git a/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.test.ts b/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.test.ts new file mode 100644 index 000000000000..076dff84a730 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.test.ts @@ -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); + }); +}); diff --git a/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.ts b/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.ts new file mode 100644 index 000000000000..54e721ebea2d --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/NgModulesAnalyzer.ts @@ -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; +}; diff --git a/app/angular/src/client/preview/angular-beta/RenderNgAppService.test.ts b/app/angular/src/client/preview/angular-beta/RenderNgAppService.test.ts new file mode 100644 index 000000000000..205e49c28665 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/RenderNgAppService.test.ts @@ -0,0 +1,240 @@ +import { Component, EventEmitter, Input, NgModule, Output, Type } from '@angular/core'; +import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; +import { StoryFnAngularReturnType } from '../types'; +import { RenderNgAppService } from './RenderNgAppService'; + +jest.mock('@angular/platform-browser-dynamic'); + +declare const document: Document; +describe('RenderNgAppService', () => { + let renderNgAppService: RenderNgAppService; + + beforeEach(async () => { + document.body.innerHTML = '
'; + (platformBrowserDynamic as any).mockImplementation(platformBrowserDynamicTesting); + renderNgAppService = new RenderNgAppService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize', () => { + expect(renderNgAppService).toBeDefined(); + }); + + describe('render', () => { + it('should add storybook-wrapper for story template', async () => { + await renderNgAppService.render( + (): StoryFnAngularReturnType => ({ + template: '🦊', + props: {}, + }), + false + ); + + expect(document.body.getElementsByTagName('storybook-wrapper')[0].innerHTML).toBe('🦊'); + }); + + it('should add storybook-wrapper for story component', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + await renderNgAppService.render( + (): StoryFnAngularReturnType => ({ + component: FooComponent, + props: {}, + }), + false + ); + + expect(document.body.getElementsByTagName('storybook-wrapper')[0].innerHTML).toBe( + '🦊' + ); + }); + }); + describe('getNgModuleMetadata', () => { + describe('with simple component', () => { + @Component({ + selector: 'foo', + template: ` +

{{ input }}

+

{{ localPropertyName }}

+

{{ localProperty }}

+

{{ localFunction() }}

+

+

+ `, + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputBindingPropertyName') + public localPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputBindingPropertyName') + public localOutput = new EventEmitter(); + + public localProperty: string; + + public localFunction = () => ''; + } + + it('should initialize inputs', async () => { + const props = { + input: 'input', + inputBindingPropertyName: 'inputBindingPropertyName', + localProperty: 'localProperty', + localFunction: () => 'localFunction', + }; + + const ngModule = renderNgAppService.getNgModuleMetadata( + { component: FooComponent, props }, + new BehaviorSubject(props) + ); + + const { fixture } = await configureTestingModule(ngModule); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(props.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + props.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + props.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + props.localFunction() + ); + }); + + it('should initialize outputs', async () => { + let expectedOutputValue: string; + let expectedOutputBindingValue: string; + const props = { + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + + const ngModule = renderNgAppService.getNgModuleMetadata( + { component: FooComponent, props }, + new BehaviorSubject(props) + ); + + const { fixture } = await configureTestingModule(ngModule); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(expectedOutputValue).toEqual('outputEmitted'); + expect(expectedOutputBindingValue).toEqual('outputEmitted'); + }); + + it('should change inputs if storyProps$ Subject emit', async () => { + const initialProps = { + input: 'input', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const ngModule = renderNgAppService.getNgModuleMetadata( + { component: FooComponent, props: initialProps }, + storyProps$ + ); + const { fixture } = await configureTestingModule(ngModule); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual( + initialProps.input + ); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + '' + ); + + const newProps = { + input: 'new input', + inputBindingPropertyName: 'new inputBindingPropertyName', + localProperty: 'new localProperty', + localFunction: () => 'new localFunction', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + newProps.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + newProps.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + newProps.localFunction() + ); + }); + + it('should not override outputs if storyProps$ Subject emit', async () => { + let expectedOutputValue; + let expectedOutputBindingValue; + const initialProps = { + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const ngModule = renderNgAppService.getNgModuleMetadata( + { component: FooComponent, props: initialProps }, + storyProps$ + ); + const { fixture } = await configureTestingModule(ngModule); + fixture.detectChanges(); + + const newProps = { + input: 'new input', + output: () => { + expectedOutputValue = 'should not be called'; + }, + outputBindingPropertyName: () => { + expectedOutputBindingValue = 'should not be called'; + }, + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(expectedOutputValue).toEqual('outputEmitted'); + expect(expectedOutputBindingValue).toEqual('outputEmitted'); + }); + }); + }); + + async function configureTestingModule(ngModule: NgModule) { + await TestBed.configureTestingModule({ + declarations: ngModule.declarations, + providers: ngModule.providers, + }).compileComponents(); + const fixture = TestBed.createComponent(ngModule.bootstrap[0] as Type); + + return { + fixture, + }; + } +}); diff --git a/app/angular/src/client/preview/angular-beta/RenderNgAppService.ts b/app/angular/src/client/preview/angular-beta/RenderNgAppService.ts new file mode 100644 index 000000000000..dbb7aa761215 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/RenderNgAppService.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-undef */ +import { enableProdMode, NgModule, PlatformRef, 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: PlatformRef; + + private staticRoot = document.getElementById('root'); + + // Observable to change the properties dynamically without reloading angular module&component + private storyProps$: Subject; + + 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.debug(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} + * @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, 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(storyObj.props); + + await this.platform.bootstrapModule( + createModuleFromMetadata(this.getNgModuleMetadata(storyObj, this.storyProps$)) + ); + } + + public getNgModuleMetadata = ( + storyFnAngular: StoryFnAngularReturnType, + storyProps$: Subject + ): 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 => { + // Template has priority over the component + const isCreatingComponentFromTemplate = !!template; + + return isCreatingComponentFromTemplate + ? createComponentClassFromStoryTemplate(template, styles) + : createComponentClassFromStoryComponent(component, props); +}; diff --git a/app/angular/src/client/preview/angular-beta/app.token.ts b/app/angular/src/client/preview/angular-beta/app.token.ts new file mode 100644 index 000000000000..fac08eeea082 --- /dev/null +++ b/app/angular/src/client/preview/angular-beta/app.token.ts @@ -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>('STORY_PROPS'); + +export const storyPropsProvider = (storyProps$: Subject): Provider => ({ + provide: STORY_PROPS, + useFactory: storyDataFactory(storyProps$.asObservable()), + deps: [NgZone], +}); + +function storyDataFactory(data: Observable) { + return (ngZone: NgZone) => + new Observable((subscriber: Subscriber) => { + const sub = data.subscribe( + (v: T) => { + ngZone.run(() => subscriber.next(v)); + }, + (err) => { + ngZone.run(() => subscriber.error(err)); + }, + () => { + ngZone.run(() => subscriber.complete()); + } + ); + + return () => { + sub.unsubscribe(); + }; + }); +} diff --git a/app/angular/src/client/preview/render.ts b/app/angular/src/client/preview/render.ts index 99cd8fedc539..1b61a7930635 100644 --- a/app/angular/src/client/preview/render.ts +++ b/app/angular/src/client/preview/render.ts @@ -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; showMain: () => void; forceRender: boolean; + parameters: Parameters; }) { showMain(); - renderNgApp(storyFn, forceRender); + + if (parameters.angularLegacyRendering) { + renderNgApp(storyFn, forceRender); + return; + } + + RenderNgAppService.getInstance().render(storyFn, forceRender); } diff --git a/app/angular/src/client/preview/types-6-0.ts b/app/angular/src/client/preview/types-6-0.ts index 5f66f80955cb..f80c4a5e3146 100644 --- a/app/angular/src/client/preview/types-6-0.ts +++ b/app/angular/src/client/preview/types-6-0.ts @@ -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 = BaseMeta & */ export type Story = BaseStory & Annotations; + +export type Parameters = DefaultParameters & { + /** Uses legacy angular rendering engine that use dynamic component */ + angularLegacyRendering?: boolean; +}; diff --git a/app/angular/src/server/__tests__/angular-cli_config.test.ts b/app/angular/src/server/__tests__/angular-cli_config.test.ts index 25c22a7e3ea9..ae09891c0209 100644 --- a/app/angular/src/server/__tests__/angular-cli_config.test.ts +++ b/app/angular/src/server/__tests__/angular-cli_config.test.ts @@ -58,7 +58,7 @@ describe('angular-cli_config', () => { getLeadingAngularCliProject(angularJsonWithNoBuildOptions); - const config = getAngularCliWebpackConfigOptions('/'); + const config = getAngularCliWebpackConfigOptions('/' as Path); expect(config).toBeNull(); }); diff --git a/app/angular/tsconfig.spec.json b/app/angular/tsconfig.spec.json new file mode 100644 index 000000000000..d52945662591 --- /dev/null +++ b/app/angular/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["webpack-env", "jest", "node"], + "typeRoots": ["../../node_modules/@types", "node_modules/@types"], + "allowJs": true + }, + "include": ["**/*.test.ts", "**/*.d.ts", "setup-jest.ts"] +} diff --git a/examples/angular-cli/src/stories/module-context/chips.module.ts b/examples/angular-cli/src/stories/module-context/chips.module.ts index b57d20d5ed83..7646823bec33 100644 --- a/examples/angular-cli/src/stories/module-context/chips.module.ts +++ b/examples/angular-cli/src/stories/module-context/chips.module.ts @@ -7,7 +7,7 @@ import { CHIP_COLOR } from './chip-color.token'; @NgModule({ imports: [CommonModule], - exports: [ChipsGroupComponent], + exports: [ChipsGroupComponent, ChipComponent], declarations: [ChipsGroupComponent, ChipComponent, ChipTextPipe], providers: [ { diff --git a/jest.config.js b/jest.config.js index 3a5bced8bbb6..b51aa50d433f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,6 +24,7 @@ module.exports = { }, projects: [ '', + '/app/angular', '/examples/cra-kitchen-sink', '/examples/cra-ts-kitchen-sink', '/examples/html-kitchen-sink', @@ -52,6 +53,7 @@ module.exports = { '/prebuilt/', 'addon-jest.test.js', '/cli/test/', + '/app/angular/*', '/examples/cra-kitchen-sink/src/*', '/examples/cra-react15/src/*', '/examples/cra-ts-kitchen-sink/src/components/*', diff --git a/tsconfig.json b/tsconfig.json index 616b3a6c8bd0..da833f0dcec0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "**/*.spec.ts", "**/__tests__", "**/*.test.ts", - "**/FlowType*" + "**/FlowType*", + "**/setup-jest.ts" ] } diff --git a/yarn.lock b/yarn.lock index 185a302998f2..2101ca709fa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30317,7 +30317,7 @@ strip-json-comments@2.0.1, strip-json-comments@^2.0.1, strip-json-comments@~2.0. resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@3.1.1, strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==