feat(angular): componentWrapperDecorator accept props

Note : compatible with StoryContext (args, globals, ...)
This commit is contained in:
ThibaudAv 2021-01-06 18:22:07 +01:00
parent 3ba763afc2
commit 3d11436e8f
6 changed files with 355 additions and 20 deletions

View File

@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, NgModule, Output, Type } from '@angular
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { ICollection } from '../types';
import { getStorybookModuleMetadata } from './StorybookModule'; import { getStorybookModuleMetadata } from './StorybookModule';
describe('StorybookModule', () => { describe('StorybookModule', () => {
@ -132,7 +133,7 @@ describe('StorybookModule', () => {
); );
}); });
it('should not override outputs if storyProps$ Subject emit', async () => { it('should override outputs if storyProps$ Subject emit', async () => {
let expectedOutputValue; let expectedOutputValue;
let expectedOutputBindingValue; let expectedOutputBindingValue;
const initialProps = { const initialProps = {
@ -155,10 +156,10 @@ describe('StorybookModule', () => {
const newProps = { const newProps = {
input: 'new input', input: 'new input',
output: () => { output: () => {
expectedOutputValue = 'should not be called'; expectedOutputValue = 'should be called';
}, },
outputBindingPropertyName: () => { outputBindingPropertyName: () => {
expectedOutputBindingValue = 'should not be called'; expectedOutputBindingValue = 'should be called';
}, },
}; };
storyProps$.next(newProps); storyProps$.next(newProps);
@ -168,8 +169,43 @@ describe('StorybookModule', () => {
fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); fixture.nativeElement.querySelector('p#outputBindingPropertyName').click();
expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input);
expect(expectedOutputValue).toEqual('outputEmitted'); expect(expectedOutputValue).toEqual('should be called');
expect(expectedOutputBindingValue).toEqual('outputEmitted'); expect(expectedOutputBindingValue).toEqual('should be called');
});
it('should change template inputs if storyProps$ Subject emit', async () => {
const initialProps = {
color: 'red',
input: 'input',
};
const storyProps$ = new BehaviorSubject<ICollection>(initialProps);
const ngModule = getStorybookModuleMetadata(
{
storyFnAngular: {
props: initialProps,
template: '<p [style.color]="color"><foo [input]="input"></foo></p>',
},
parameters: { component: FooComponent },
},
storyProps$
);
const { fixture } = await configureTestingModule(ngModule);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p').style.color).toEqual('red');
expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(
initialProps.input
);
const newProps = {
color: 'black',
input: 'new input',
};
storyProps$.next(newProps);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p').style.color).toEqual('black');
expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input);
}); });
}); });
}); });

View File

@ -48,19 +48,30 @@ export const createStorybookWrapperComponent = (
styles, styles,
}) })
class StorybookWrapperComponent implements AfterViewInit, OnDestroy { class StorybookWrapperComponent implements AfterViewInit, OnDestroy {
private storyPropsSubscription: Subscription; private storyComponentPropsSubscription: Subscription;
private storyWrapperPropsSubscription: Subscription;
@ViewChild(storyComponent ?? '', { static: true }) storyComponentElementRef: ElementRef; @ViewChild(storyComponent ?? '', { static: true }) storyComponentElementRef: ElementRef;
@ViewChild(storyComponent ?? '', { read: ViewContainerRef, static: true }) @ViewChild(storyComponent ?? '', { read: ViewContainerRef, static: true })
storyComponentViewContainerRef: ViewContainerRef; storyComponentViewContainerRef: ViewContainerRef;
// eslint-disable-next-line no-useless-constructor
constructor( constructor(
@Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>, @Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>,
private changeDetectorRef: ChangeDetectorRef private changeDetectorRef: ChangeDetectorRef
) { ) {}
// Initializes Inputs/Outputs values
Object.assign(this, initialProps); ngOnInit(): void {
// Subscribes to the observable storyProps$ to keep these properties up to date
this.storyWrapperPropsSubscription = this.storyProps$.subscribe((storyProps = {}) => {
// All props are added as component properties
Object.assign(this, storyProps);
this.changeDetectorRef.detectChanges();
this.changeDetectorRef.markForCheck();
});
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@ -81,7 +92,7 @@ export const createStorybookWrapperComponent = (
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
// Once target component has been initialized, the storyProps$ observable keeps target component inputs up to date // Once target component has been initialized, the storyProps$ observable keeps target component inputs up to date
this.storyPropsSubscription = this.storyProps$ this.storyComponentPropsSubscription = this.storyProps$
.pipe( .pipe(
skip(1), skip(1),
map((props) => { map((props) => {
@ -128,8 +139,11 @@ export const createStorybookWrapperComponent = (
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.storyPropsSubscription != null) { if (this.storyComponentPropsSubscription != null) {
this.storyPropsSubscription.unsubscribe(); this.storyComponentPropsSubscription.unsubscribe();
}
if (this.storyWrapperPropsSubscription != null) {
this.storyWrapperPropsSubscription.unsubscribe();
} }
} }
} }

View File

@ -1,9 +1,9 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { Type } from '@angular/core'; import { Type } from '@angular/core';
import { DecoratorFunction } from '@storybook/addons'; import { DecoratorFunction, StoryContext } from '@storybook/addons';
import { computesTemplateFromComponent } from '../angular-beta/ComputesTemplateFromComponent'; import { computesTemplateFromComponent } from '../angular-beta/ComputesTemplateFromComponent';
import { isComponent } from '../angular-beta/utils/NgComponentAnalyzer'; import { isComponent } from '../angular-beta/utils/NgComponentAnalyzer';
import { NgModuleMetadata, StoryFnAngularReturnType } from '../types'; import { ICollection, NgModuleMetadata, StoryFnAngularReturnType } from '../types';
export const moduleMetadata = (metadata: Partial<NgModuleMetadata>) => (storyFn: () => any) => { export const moduleMetadata = (metadata: Partial<NgModuleMetadata>) => (storyFn: () => any) => {
const story = storyFn(); const story = storyFn();
@ -26,13 +26,26 @@ export const moduleMetadata = (metadata: Partial<NgModuleMetadata>) => (storyFn:
}; };
export const componentWrapperDecorator = ( export const componentWrapperDecorator = (
element: Type<unknown> | ((story: string) => string) element: Type<unknown> | ((story: string) => string),
): DecoratorFunction<StoryFnAngularReturnType> => (storyFn) => { props?: ICollection | ((storyContext: StoryContext) => ICollection)
): DecoratorFunction<StoryFnAngularReturnType> => (storyFn, storyContext) => {
const story = storyFn(); const story = storyFn();
const currentProps = typeof props === 'function' ? (props(storyContext) as ICollection) : props;
const template = isComponent(element) const template = isComponent(element)
? computesTemplateFromComponent(element, {}, story.template) ? computesTemplateFromComponent(element, currentProps ?? {}, story.template)
: element(story.template); : element(story.template);
return { ...story, template }; return {
...story,
template,
...(currentProps || story.props
? {
props: {
...currentProps,
...story.props,
},
}
: {}),
};
}; };

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, Input, Output } from '@angular/core';
import { DecoratorFunction, StoryContext } from '@storybook/addons'; import { DecoratorFunction, StoryContext } from '@storybook/addons';
import { componentWrapperDecorator } from './angular/decorators'; import { componentWrapperDecorator } from './angular/decorators';
@ -7,6 +7,77 @@ import { StoryFnAngularReturnType } from './types';
describe('decorateStory', () => { describe('decorateStory', () => {
describe('angular behavior', () => { describe('angular behavior', () => {
it('should use componentWrapperDecorator with args', () => {
const decorators: DecoratorFunction<StoryFnAngularReturnType>[] = [
componentWrapperDecorator(ParentComponent, ({ args }) => args),
componentWrapperDecorator(
(story) => `<grandparent [grandparentInput]="grandparentInput">${story}</grandparent>`,
({ args }) => args
),
componentWrapperDecorator((story) => `<great-grandparent>${story}</great-grandparent>`),
];
const decorated = decorateStory(() => ({ template: '</child>' }), decorators);
expect(
decorated(
makeContext({
parameters: { component: FooComponent },
args: {
parentInput: 'Parent input',
grandparentInput: 'grandparent input',
parentOutput: () => {},
},
})
)
).toEqual({
props: {
parentInput: 'Parent input',
grandparentInput: 'grandparent input',
parentOutput: expect.any(Function),
},
template:
'<great-grandparent><grandparent [grandparentInput]="grandparentInput"><parent [parentInput]="parentInput" (parentOutput)="parentOutput($event)"></child></parent></grandparent></great-grandparent>',
});
});
it('should use componentWrapperDecorator with input / output', () => {
const decorators: DecoratorFunction<StoryFnAngularReturnType>[] = [
componentWrapperDecorator(ParentComponent, {
parentInput: 'Parent input',
parentOutput: () => {},
}),
componentWrapperDecorator(
(story) => `<grandparent [grandparentInput]="grandparentInput">${story}</grandparent>`,
{
grandparentInput: 'Grandparent input',
sameInput: 'Should be override by story props',
}
),
componentWrapperDecorator((story) => `<great-grandparent>${story}</great-grandparent>`),
];
const decorated = decorateStory(
() => ({ template: '</child>', props: { sameInput: 'Story input' } }),
decorators
);
expect(
decorated(
makeContext({
parameters: { component: FooComponent },
})
)
).toEqual({
props: {
parentInput: 'Parent input',
parentOutput: expect.any(Function),
grandparentInput: 'Grandparent input',
sameInput: 'Story input',
},
template:
'<great-grandparent><grandparent [grandparentInput]="grandparentInput"><parent [parentInput]="parentInput" (parentOutput)="parentOutput($event)"></child></parent></grandparent></great-grandparent>',
});
});
it('should use componentWrapperDecorator', () => { it('should use componentWrapperDecorator', () => {
const decorators: DecoratorFunction<StoryFnAngularReturnType>[] = [ const decorators: DecoratorFunction<StoryFnAngularReturnType>[] = [
componentWrapperDecorator(ParentComponent), componentWrapperDecorator(ParentComponent),
@ -249,4 +320,10 @@ class FooComponent {}
selector: 'parent', selector: 'parent',
template: `<ng-content></ng-content>`, template: `<ng-content></ng-content>`,
}) })
class ParentComponent {} class ParentComponent {
@Input()
parentInput: string;
@Output()
parentOutput: any;
}

View File

@ -39,6 +39,86 @@ exports[`Storyshots Core / Decorators With Component 1`] = `
</storybook-wrapper> </storybook-wrapper>
`; `;
exports[`Storyshots Core / Decorators With Component Decorator And Args 1`] = `
<storybook-wrapper>
Grandparent<br /><div
style="margin: 3em; border:solid;"
>
<parent-component>
Parent
<br />
Input text:
<br />
Output :
<button>
Click here !
</button>
<br />
<div
style="margin: 3em; border:solid;"
>
<child-component
ng-reflect-child-text="Child text"
>
Child
<br />
Input text: Child text
<br />
Output :
<button>
Click here !
</button>
<br />
Private text: Child private text
<br />
</child-component>
</div>
</parent-component>
</div>
</storybook-wrapper>
`;
exports[`Storyshots Core / Decorators With Component Decorator And Props 1`] = `
<storybook-wrapper>
Grandparent<br /><div
style="margin: 3em; border:solid;"
>
<parent-component
ng-reflect-parent-text="Parent text"
>
Parent
<br />
Input text: Parent text
<br />
Output :
<button>
Click here !
</button>
<br />
<div
style="margin: 3em; border:solid;"
>
<child-component
ng-reflect-child-text="Child text"
>
Child
<br />
Input text: Child text
<br />
Output :
<button>
Click here !
</button>
<br />
Private text: Child private text
<br />
</child-component>
</div>
</parent-component>
</div>
</storybook-wrapper>
`;
exports[`Storyshots Core / Decorators With Component Wrapper Decorator 1`] = ` exports[`Storyshots Core / Decorators With Component Wrapper Decorator 1`] = `
<storybook-wrapper> <storybook-wrapper>
Grandparent<br /><div Grandparent<br /><div
@ -78,6 +158,86 @@ exports[`Storyshots Core / Decorators With Component Wrapper Decorator 1`] = `
</storybook-wrapper> </storybook-wrapper>
`; `;
exports[`Storyshots Core / Decorators With Component Wrapper Decorator And Args 1`] = `
<storybook-wrapper>
Grandparent<br /><div
style="margin: 3em; border:solid;"
>
<parent-component>
Parent
<br />
Input text:
<br />
Output :
<button>
Click here !
</button>
<br />
<div
style="margin: 3em; border:solid;"
>
<child-component
ng-reflect-child-text="Child text"
>
Child
<br />
Input text: Child text
<br />
Output :
<button>
Click here !
</button>
<br />
Private text: Child private text
<br />
</child-component>
</div>
</parent-component>
</div>
</storybook-wrapper>
`;
exports[`Storyshots Core / Decorators With Component Wrapper Decorator And Props 1`] = `
<storybook-wrapper>
Grandparent<br /><div
style="margin: 3em; border:solid;"
>
<parent-component
ng-reflect-parent-text="Parent text"
>
Parent
<br />
Input text: Parent text
<br />
Output :
<button>
Click here !
</button>
<br />
<div
style="margin: 3em; border:solid;"
>
<child-component
ng-reflect-child-text="Child text"
>
Child
<br />
Input text: Child text
<br />
Output :
<button>
Click here !
</button>
<br />
Private text: Child private text
<br />
</child-component>
</div>
</parent-component>
</div>
</storybook-wrapper>
`;
exports[`Storyshots Core / Decorators With Custom Decorator 1`] = ` exports[`Storyshots Core / Decorators With Custom Decorator 1`] = `
<storybook-wrapper> <storybook-wrapper>
Grandparent<br /><div Grandparent<br /><div

View File

@ -13,6 +13,7 @@ export default {
), ),
], ],
args: { childText: 'Child text', childPrivateText: 'Child private text' }, args: { childText: 'Child text', childPrivateText: 'Child private text' },
argTypes: { onClickChild: { action: 'onClickChild' } },
} as Meta; } as Meta;
export const WithTemplate = (args) => ({ export const WithTemplate = (args) => ({
@ -46,6 +47,40 @@ WithComponentWrapperDecorator.decorators = [
componentWrapperDecorator(ParentComponent), componentWrapperDecorator(ParentComponent),
]; ];
export const WithComponentWrapperDecoratorAndProps = (args) => ({
component: ChildComponent,
props: {
...args,
},
});
WithComponentWrapperDecoratorAndProps.decorators = [
moduleMetadata({ declarations: [ParentComponent] }),
componentWrapperDecorator(ParentComponent, {
parentText: 'Parent text',
onClickParent: () => {
console.log('onClickParent');
},
}),
];
export const WithComponentWrapperDecoratorAndArgs = (args) => ({
component: ChildComponent,
props: {
...args,
},
});
WithComponentWrapperDecoratorAndArgs.argTypes = {
parentText: { control: { type: 'text' } },
onClickParent: { action: 'onClickParent' },
};
WithComponentWrapperDecoratorAndArgs.decorators = [
moduleMetadata({ declarations: [ParentComponent] }),
componentWrapperDecorator(ParentComponent, ({ args }) => ({
parentText: args.parentText,
onClickParent: args.onClickParent,
})),
];
export const WithCustomDecorator = (args) => ({ export const WithCustomDecorator = (args) => ({
template: `Child Template`, template: `Child Template`,
props: { props: {