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>${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,
+ 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==