Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Angular: Overhaul preview renderer #13215

Merged
merged 7 commits into from Dec 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 16 additions & 0 deletions MIGRATION.md
@@ -1,5 +1,7 @@
<h1>Migration</h1>

- [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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/angular/jest.config.js
@@ -0,0 +1,4 @@
module.exports = {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
};
8 changes: 6 additions & 2 deletions app/angular/package.json
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions app/angular/setup-jest.ts
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import 'jest-preset-angular';
@@ -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<any> => {
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<ICollection | undefined>,
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;
};
@@ -0,0 +1,50 @@
import { Inject, ChangeDetectorRef, Component, OnDestroy, OnInit, Type } from '@angular/core';
import { Subject, Subscription } from 'rxjs';

import { ICollection } from '../types';
import { STORY_PROPS } from './app.token';
import { RenderNgAppService } from './RenderNgAppService';

/**
* Wraps the story template into a component
*
* @param template {string}
* @param styles {string[]}
*/
export const createComponentClassFromStoryTemplate = (
template: string,
styles: string[]
): Type<any> => {
@Component({
selector: RenderNgAppService.SELECTOR_STORYBOOK_WRAPPER,
template,
styles,
})
class StoryBookTemplateWrapperComponent implements OnInit, OnDestroy {
private storyPropsSubscription: Subscription;

// eslint-disable-next-line no-useless-constructor
constructor(
@Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>,
private changeDetectorRef: ChangeDetectorRef
) {}

ngOnInit(): void {
// Subscribes to the observable storyProps$ to keep these properties up to date
this.storyPropsSubscription = this.storyProps$.subscribe((storyProps = {}) => {
// All props are added as component properties
Object.assign(this, storyProps);

this.changeDetectorRef.detectChanges();
this.changeDetectorRef.markForCheck();
});
}

ngOnDestroy(): void {
if (this.storyPropsSubscription != null) {
this.storyPropsSubscription.unsubscribe();
}
}
}
return StoryBookTemplateWrapperComponent;
};
@@ -0,0 +1,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<Event>();

@Output('outputPropertyName')
public outputWithBindingPropertyName = new EventEmitter<Event>();
}

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<Event>();

@Output('outputPropertyName')
public outputWithBindingPropertyName = new EventEmitter<Event>();
}

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<T extends Type<any>>(component: T): ComponentFactory<T> {
TestBed.configureTestingModule({
declarations: [component],
}).overrideModule(BrowserDynamicTestingModule, {
set: {
entryComponents: [component],
},
});
const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver);

return componentFactoryResolver.resolveComponentFactory(component);
}