Skip to content

Commit

Permalink
Merge pull request #13939 from storybookjs/angular/component-without-…
Browse files Browse the repository at this point in the history
…selector

Angular: Support angular components without selector
  • Loading branch information
shilman authored Feb 20, 2021
2 parents 173bad8 + 703187f commit 7a12579
Show file tree
Hide file tree
Showing 11 changed files with 343 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Component } from '@angular/core';
import { ArgTypes } from '@storybook/api';
import { computesTemplateSourceFromComponent } from './ComputesTemplateFromComponent';
import { ButtonAccent, InputComponent, ISomeInterface } from './__testfixtures__/input.component';
Expand All @@ -10,6 +11,24 @@ describe('angular source decorator', () => {
const source = computesTemplateSourceFromComponent(component, props, argTypes);
expect(source).toEqual('<doc-button></doc-button>');
});

describe('with component without selector', () => {
@Component({
template: `The content`,
})
class WithoutSelectorComponent {}

it('should add component ng-container', async () => {
const component = WithoutSelectorComponent;
const props = {};
const argTypes: ArgTypes = {};
const source = computesTemplateSourceFromComponent(component, props, argTypes);
expect(source).toEqual(
`<ng-container *ngComponentOutlet="WithoutSelectorComponent"></ng-container>`
);
});
});

describe('no argTypes', () => {
it('should generate tag-only template with no props', () => {
const component = InputComponent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export const computesTemplateFromComponent = (
const ngComponentMetadata = getComponentDecoratorMetadata(component);
const ngComponentInputsOutputs = getComponentInputsOutputs(component);

if (!ngComponentMetadata.selector) {
// Allow to add renderer component when NgComponent selector is undefined
return `<ng-container *ngComponentOutlet="storyComponent"></ng-container>`;
}

const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes(
ngComponentInputsOutputs,
initialProps
Expand Down Expand Up @@ -93,6 +98,12 @@ export const computesTemplateSourceFromComponent = (
if (!ngComponentMetadata) {
return null;
}

if (!ngComponentMetadata.selector) {
// Allow to add renderer component when NgComponent selector is undefined
return `<ng-container *ngComponentOutlet="${component.name}"></ng-container>`;
}

const ngComponentInputsOutputs = getComponentInputsOutputs(component);
const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes(
ngComponentInputsOutputs,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, NgModule, Output, Type } from '@angular/core';

import { TestBed } from '@angular/core/testing';
import { BrowserModule } from '@angular/platform-browser';
import { BehaviorSubject } from 'rxjs';
import { ICollection } from '../types';
import { getStorybookModuleMetadata } from './StorybookModule';
Expand Down Expand Up @@ -208,13 +209,47 @@ describe('StorybookModule', () => {
expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input);
});
});

describe('with component without selector', () => {
@Component({
template: `The content`,
})
class WithoutSelectorComponent {}

it('should display the component', async () => {
const props = {};

const ngModule = getStorybookModuleMetadata(
{
storyFnAngular: {
props,
moduleMetadata: { entryComponents: [WithoutSelectorComponent] },
},
parameters: { component: WithoutSelectorComponent },
},
new BehaviorSubject<ICollection>(props)
);

const { fixture } = await configureTestingModule(ngModule);
fixture.detectChanges();

expect(fixture.nativeElement.innerHTML).toContain('The content');
});
});
});

async function configureTestingModule(ngModule: NgModule) {
await TestBed.configureTestingModule({
declarations: ngModule.declarations,
providers: ngModule.providers,
}).compileComponents();
})
.overrideModule(BrowserModule, {
set: {
entryComponents: [...ngModule.entryComponents],
},
})
.compileComponents();

const fixture = TestBed.createComponent(ngModule.bootstrap[0] as Type<unknown>);

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export const createStorybookWrapperComponent = (
@ViewChild(storyComponent ?? '', { read: ViewContainerRef, static: true })
storyComponentViewContainerRef: ViewContainerRef;

// Used in case of a component without selector
storyComponent = storyComponent ?? '';

// eslint-disable-next-line no-useless-constructor
constructor(
@Inject(STORY_PROPS) private storyProps$: Subject<ICollection | undefined>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Storyshots Basics / Component / without selector / Custom wrapper *NgComponentOutlet Custom wrapper *NgComponentOutlet 1`] = `
<storybook-wrapper>
<ng-component-outlet-wrapper
ng-reflect-color="green"
ng-reflect-component-outlet="function WithoutSelectorCompon"
ng-reflect-name="Dixie Normous"
>

<ng-component>
My name in color :
<div
style="color: green;"
>
Dixie Normous
</div>
Ng-content : Inspired by
https://angular.io/api/common/NgComponentOutlet
</ng-component>
</ng-component-outlet-wrapper>
</storybook-wrapper>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Storyshots Basics / Component / without selector / Custom wrapper ComponentFactoryResolver Custom wrapper ComponentFactoryResolver 1`] = `
<storybook-wrapper>
<component-factory-wrapper
ng-reflect-args="[object Object]"
ng-reflect-component-outlet="function WithoutSelectorCompon"
/><ng-component>
My name in color :
<div
style="color: chartreuse;"
>
Dixie Normous
</div>
</ng-component>
</storybook-wrapper>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Storyshots Basics / Component / without selector Simple Component 1`] = `
<storybook-wrapper>
<ng-component>
My name in color :
<div
style="color: rgb(30, 136, 229);"
>
Joe Bar
</div>
</ng-component>
</storybook-wrapper>
`;

exports[`Storyshots Basics / Component / without selector With Injection Token And Args 1`] = `
<storybook-wrapper>
<ng-component>
My name in color :
<div
style="color: red;"
>
Dixie Normous
</div>
</ng-component>
</storybook-wrapper>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Component, Injector, Input, OnInit, Type } from '@angular/core';
import { componentWrapperDecorator, moduleMetadata, Story, Meta } from '@storybook/angular';
import { WithoutSelectorComponent, WITHOUT_SELECTOR_DATA } from './without-selector.component';

export default {
title: 'Basics / Component / without selector / Custom wrapper *NgComponentOutlet',
component: WithoutSelectorComponent,
decorators: [
moduleMetadata({
entryComponents: [WithoutSelectorComponent],
}),
],
} as Meta;

// Advanced example with custom *ngComponentOutlet

@Component({
selector: 'ng-component-outlet-wrapper',
template: `<ng-container
*ngComponentOutlet="componentOutlet; injector: componentInjector; content: componentContent"
></ng-container>`,
})
class NgComponentOutletWrapperComponent implements OnInit {
@Input()
componentOutlet: Type<unknown>;

@Input()
name: string;

@Input()
color: string;

componentInjector: Injector;

componentContent = [
// eslint-disable-next-line no-undef
[document.createTextNode('Ng-content : Inspired by ')],
// eslint-disable-next-line no-undef
[document.createTextNode('https://angular.io/api/common/NgComponentOutlet')],
];

// eslint-disable-next-line no-useless-constructor
constructor(private readonly injector: Injector) {}

ngOnInit(): void {
this.componentInjector = Injector.create({
providers: [
{ provide: WITHOUT_SELECTOR_DATA, useValue: { color: this.color, name: this.name } },
],
parent: this.injector,
});
}
}

// Live changing of args by controls does not work at the moment. When changing args storybook does not fully
// reload and therefore does not take into account the change of provider.
export const WithCustomNgComponentOutletWrapper: Story = (args) => ({
props: args,
});
WithCustomNgComponentOutletWrapper.storyName = 'Custom wrapper *NgComponentOutlet';
WithCustomNgComponentOutletWrapper.argTypes = {
name: { control: 'text' },
color: { control: 'color' },
};
WithCustomNgComponentOutletWrapper.args = { name: 'Dixie Normous', color: 'green' };
WithCustomNgComponentOutletWrapper.decorators = [
moduleMetadata({
declarations: [NgComponentOutletWrapperComponent],
}),
componentWrapperDecorator(NgComponentOutletWrapperComponent, (args) => ({
name: args.name,
color: args.color,
componentOutlet: WithoutSelectorComponent,
})),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
AfterViewInit,
Component,
ComponentFactoryResolver,
Input,
Type,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { componentWrapperDecorator, moduleMetadata, Story, Meta } from '@storybook/angular';

import { WithoutSelectorComponent } from './without-selector.component';

export default {
title: 'Basics / Component / without selector / Custom wrapper ComponentFactoryResolver',
component: WithoutSelectorComponent,
decorators: [
moduleMetadata({
entryComponents: [WithoutSelectorComponent],
}),
],
} as Meta;

// Advanced example with custom ComponentFactoryResolver

@Component({ selector: 'component-factory-wrapper', template: '' })
class ComponentFactoryWrapperComponent implements AfterViewInit {
@ViewChild('dynamicInsert', { read: ViewContainerRef }) dynamicInsert;

@Input()
componentOutlet: Type<unknown>;

@Input()
args: any;

// eslint-disable-next-line no-useless-constructor
constructor(
private viewContainerRef: ViewContainerRef,
private componentFactoryResolver: ComponentFactoryResolver
) {}

ngAfterViewInit() {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(
this.componentOutlet
);
const containerRef = this.viewContainerRef;
containerRef.clear();
const dynamicComponent = containerRef.createComponent(componentFactory);
Object.assign(dynamicComponent.instance, this.args);
}
}

// Live changing of args by controls does not work at the moment. When changing args storybook does not fully
// reload and therefore does not take into account the change of provider.
export const WithComponentFactoryResolver: Story = (args) => ({
props: args,
});
WithComponentFactoryResolver.storyName = 'Custom wrapper ComponentFactoryResolver';
WithComponentFactoryResolver.argTypes = {
name: { control: 'text' },
color: { control: 'color' },
};
WithComponentFactoryResolver.args = { name: 'Dixie Normous', color: 'chartreuse' };
WithComponentFactoryResolver.decorators = [
moduleMetadata({
declarations: [ComponentFactoryWrapperComponent],
}),
componentWrapperDecorator(ComponentFactoryWrapperComponent, ({ args }) => ({
args,
componentOutlet: WithoutSelectorComponent,
})),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Component, Inject, InjectionToken, Optional } from '@angular/core';

export const WITHOUT_SELECTOR_DATA = new InjectionToken<{ color: string; name: string }>(
'WITHOUT_SELECTOR_DATA'
);

@Component({
template: `My name in color :
<div [style.color]="color">{{ name }}</div>
<ng-content></ng-content> <ng-content></ng-content>`,
})
export class WithoutSelectorComponent {
color = '#1e88e5';

name = 'Joe Bar';

constructor(
@Inject(WITHOUT_SELECTOR_DATA)
@Optional()
data: {
color: string;
name: string;
} | null
) {
if (data) {
this.color = data.color;
this.name = data.name;
}
}
}
Loading

0 comments on commit 7a12579

Please sign in to comment.