This is a proposal to drastically simplify the process of creating Angular decorators that wrap the story component in a different component that provides additional functionality.
A prototype of the following can be found here:
https://github.com/jonrimmer/storybook/commit/68391b3ba0f051d263c581cecdaa703e4ba3a2a2
Currently, it is trivial for someone using React to write a decorator that wraps the story in another component:
storiesOf('MyComponent', module)
.addDecorator(story => (
<MyWrapperComponent title={'Wrapper'}>
{story()}
</MyWrapperComponent>
))
.add('without props', () => <MyComponent />)
This lets add-ons like Background easily decorate stories with a component that provides some custom styling or UI.
Unfortunately, this is not possible in Angular, as the framework does not support rendering an arbitrary tree of components produced by something like JSX.
I propose to add a new, optional child
property to NgStory
story definitions, which lets a story nest another. Decorators that wish to wrap the child story will return a new story that contains the wrapped story as the child property, as follows:
const wrappingDecorator: story => ({
component: MyWrapperComponent,
moduleMetadata: { ... },
child: story()
});
The only requirement for MyWrapperComponent
is that it contains a standard <ng-content>
directive, indicating to Angular where to place the child content.
The result will be a linked list of decorated stories. The Storybook Angular renderer will then recurse through this list, combining the moduleMetadata of each nested story. See app/angular/src/client/preview/angular/helpers.js
in the prototype for how this will work.
The app root component will also recurse through the list, using Angular's component factory API to create the components and project the node of each child into its parent. See app/angular/src/client/preview/angular/components/app.component.ts
for how this will work.
The prototype needs more work to improve robustness, add tests and documentation, etc. However, I didn't want to sink any more time into it without knowing whether this was a viable direction to go, and in case it conflicted with work others had already done / planned.
This approach would open up the interesting possibility of adding a custom JSX factory for Angular stories. This would convert conventional JSX syntax into something resembling the structure above (perhaps with additional functionality for representing regular HTML nodes).
I think having a new "child" property will complicate a bit more an existing api which is already kinda complicated. Also IMO using "component" is something that we would like to deprecate one day, and encourage people to use "template" which provides a more robust solution.
What do you think about something like this:
storyOf('blah', module)
.addDecorator((storyFn, context) => {
const story = storyFn();
return {
...smartExtend(story, { /* decorator metadata */ })
template `<decorated-component>${story.template}</decorated-component>`
}
})
where smartExtend
is something like this
function smartExtend(meta1, meta2) {
return {
props {
...meta1,
...meta2.
},
moduleMetadata: {
imports: [...meta1.imports, ...mets2.imports]
// other things
}
}
}
Hmm. Nesting the templates is a nice idea. However, as it stands it seems you have a problem with prop name collisions. E.g. if my story has a prop called title
, and so does a decorator, then they're going to collide. You'd need to require all decorators to uniquely namespace their props. Or you'd need smartExtend to check each new prop for collisions and, if you find one, mangle its name to something unique, then replace every instance of the original name in the template with the mangled one (while avoiding false positives). It could get messy.
Also, I'm not keen on templates as they stand, because I need to explicitly add bindings to a separate props object for things, like actions, which aren't simple objects (or need to calculated within your story). This is where JSX shines, as it let you easily express inline bindings to something in the regular JS scope. Needing to do this all manually hurts the usability of the Angular version of Storybook, I think.
However, tagged template literals could provide a solution to both problems, using regular JS. They let you create a function that processes a tagged template and its placeholder expressions, and they can return something other than a string.
Consider the following tagged template function:
function ngTemplate(strings: TemplateStringsArray, ...propExpressions: any[]) {
return strings.reduce((acc, strValue, index) => {
acc.template += strValue;
if (index < propExpressions.length) {
const propKey = '__storybook_prop_' + index;
acc.template += propKey;
acc.props[propKey] = propExpressions[index];
}
return acc;
}, {
template: '',
props: {}
})
}
This will a produce a data structure like { template, props }
where template
is the original template, but with the placeholder expressions replaced with generated keys that reference properties of the props
object — which are the actual expressions.
E.g. If I used it as follows:
const someString = 'Test';
const myStory = ngTemplate`<my-button>{{ ${someString} }}</my-button>`
Then myStory
would equal:
{
template: '<my-button>{{ __storybook_prop_0 }}</my-button>',
props: {
__storybook_prop_0: 'Test'
}
}
Or, a more complex example:
const action = fn => fn;
const someString = 'Test';
const myStory = ngTemplate`
<my-component (click)="${ action(() => doSomething()) }">
{{ ${someString} }}
</my-component>
`;
Then myStory
equals:
{
template: '\n <my-component (click)="__storybook_prop_0>\n {{ __storybook_prop_1 }}\n </my-component>',
props: {
__storybook_prop_0: [Function],
__storybook_prop_1: 'Test'
}
}
As for nested templates, the ngTemplate
template function could be extended to detect when one of the expressions is itself a story, and have special logic for handling these. It would do similar logic to your proposed smartExtend
, but could merge the props in an easier way, as they would have predictable names.
I create a gist with some runnable code here: https://gist.github.com/jonrimmer/ee128e88ecf850dcb80cf548d1732565
Looks promising. I love the idea of a template function. Would you like to add it to your existing PR?
I think I'd like to do it as a separate PR, as I imagine it's going to take a bit of time to develop and writing a proper suite of tests. I'm hoping to get the other PR finalised over the weekend, then move on to this. I realised Polymer's lit-html library covers some of the same ground, although their use-case is a bit different, as they're focused on coercing objects to strings. However, they have some code that could be useful, such as extracting the name of an attribute that goes along with an expression, which would help for debugging purposes.
Hi everyone! Seems like there hasn't been much going on in this issue lately. If there are still questions, comments, or bugs, please feel free to continue the discussion. Unfortunately, we don't have time to get to every issue. We are always open to contributions so please send us a pull request if you would like to help. Inactive issues will be closed after 30 days. Thanks!
Hey there, it's me again! I am going close this issue to help our maintainers focus on the current development roadmap instead. If the issue mentioned is still a concern, please open a new ticket and mention this old one. Cheers and thanks for using Storybook!
Has this been solved yet? I want to have wrappers for my angular components.
@matthewharwood My idea of using a template function didn't really work out, as it seemed interact badly with addons like knobs.
However, you can add a small helper function to your app that will let you easily create simple template-wrapping decorators in the manner @igor-dv suggested:
const wrap = templateFn => storyFn => {
const story = storyFn();
return {
...story,
template: templateFn(story.template)
};
};
This can then be used as follows:
storiesOf('Something', module)
.addDecorator(
wrap(content => `<div style="background-color: black">${ content }<div>`)
)
.add('First', () => ({ template: `...` }))
.add('Second', () => ({ template: `...` }))
@jonrimmer As far as I understand we still don't have ability to use wrappers for stories declared with component and not template, right?
Generally I would like to add some theme switcher wrapper component on the top of every story to have ability to easily show components in different themes. I didn't found any proper solution for that other than writing some custom addon.
@igor-dv probably you have some ideas about that? It would be fine to understand in what direction should I move to solve the task
@andrei-ilyukovich Not AFAIK. @igor-dv said above they wanted to deprecate this approach in favour of the template-based one, so I didn't spend much more time thinking about it.
That said, I believe it will be easier to do this type of component wrapping when the Angular team ship their new Ivy renderer. In his recent ngConf keynote Misko Hevery mentioned it will allow for meta-programming capabilities like higher-order components. This would hopefully one day (Angular 8?) let you create a wrapping component as easily as you can currently do in React, without having to add anything specific to Storybook.
@jonrimmer thanks for the answer. For time being I could create story with theme switcher component in config.js, it will contains buttons/selector to choose the theme I want to show for the rest of components(it just switches theme stylesheets in head section). To change theme it would be necessary to open that story again and select new one. It is not so elegant but working solution
@andrei-ilyukovich It will be nice if you could share something here for other people that encounter the same issue.
The @jonmilner solution worked for me, so I need something more.
I have in my application a grid markdown component to help us to style components.
We usually use in story like this.
// Story
storiesOf('LayoutModule / FooterComponent', module)
.addDecorator(metadata)
.addDecorator(gridWrap)
.add(
'default',
() => ({
template: `
<footer [footer]='footer'></footer>
`,
props: {
footer
}
})
);
I created a wrapper to this template and we want to set this as a global decorator.
export const wrap = (templateFn: Function, props?: ICollection) => storyFn => {
const story = storyFn();
return {
...story,
props: { ...story.props, ...props },
template: templateFn(story.template)
};
};
export const gridProps = { showGrid: boolean('Show Grid', true) };
export const gridWrap = wrap(
content => `<grid-overlay *ngIf="showGrid"></grid-overlay>${content}`,
gridProps
);
The grid component and the knob are displayed, but it is not changing the template state
@felipe-norato It's not changing the template state because the knob store is not created as the current story is not yet decorated with the withKnobs
. Here's the workaround I'm using on my project to have a wrapper with a specific class attribute around all components. This way, I have a knob on all the stories and I can easily change the displayed theme.
In theme-decorator.js:
import { withKnobs, select } from '@storybook/addon-knobs';
import { themes, DEFAULT_THEME } from './themes';
export const withTheme = (story, context) => {
const storyWithKnobs = withKnobs(story, context);
return {
...storyWithKnobs,
props: {
...storyWithKnobs.props,
...{
theme: select('Theme', themes, DEFAULT_THEME)
}
},
template: `<div data-theme-decorator [ngClass]="theme">${storyWithKnobs.template}</div>`
};
};
In config.js:
import { withTheme } from './theme-decorator';
addDecorator(withTheme);
@fvilers Thanks a lot, it works for me!
@fvilers Great workaround. I would like to create a addon that would do this... but this will work for now.
@martinmcwhorter Let me know if I can help ;-)
In case someone wants to wrap the stories in Angular components and not standard HTML, I had to use the following approach:
export const withTheme = (story, context) => {
const storyWithKnobs = withKnobs(story, context);
return {
...storyWithKnobs,
props: {
...storyWithKnobs.props,
...{
theme: select('Theme', themes, DEFAULT_THEME)
}
},
template: `<theme [theme]="theme">${storyWithKnobs.template}</theme>`,
moduleMetadata: {
declarations: [ThemeComponent, ...storyWithKnobs.moduleMetadata.declarations]
}
};
};
This way, the wrapping component as well as the declarations from the wrapped component are merged.
How to wrap the component from the story as I'm not using a template?
Exmaple:
storiesOf('Angular component', module)
.addDecorator(withTheme)
.add(`Wrapping the component'`, () => ({
component: SomeComponentFromAngular,
}));
@Kosmonaft I played around with the implementations above a bit and ended up with this kind of rough version for Angular components:
export const componentWrapper = templateFn => storyFn => {
const story = storyFn();
const selector: string = story.component.__annotations__[ 0 ].selector;
const inputs: string[] = [];
const outputs: string[] = [];
const componentProps = story.component.__prop__metadata__;
for ( const key in componentProps ) {
if ( Object.getPrototypeOf( componentProps[ key ][ 0 ] ).ngMetadataName === 'Input' && story.props.hasOwnProperty( key ) ) {
inputs.push( key );
} else if ( Object.getPrototypeOf( componentProps[ key ][ 0 ] ).ngMetadataName === 'Output' && story.props.hasOwnProperty( key ) ) {
outputs.push( key );
}
}
const inputStr: string = inputs.map( input => `[${ input }]="${ input }"` ).join( ' ' );
const outputStr: string = outputs.map( output => `(${ output })="${ output }"` ).join( ' ' );
const template = `<${ selector } ${ inputStr } ${ outputStr }></${ selector }>`;
return {
...story,
template: templateFn( template )
};
};
And now I can create a story without template like this:
storiesOf( 'MyComponent', module )
.addDecorator(
componentWrapper( content =>
`<div>
${ content }
</div>`
)
)
.add( 'component story', () => ({
component: MyComponent,
props: {
...
}
}) )
Not sure if it works in every situation (do different browsers break something? Tested it with Chrome and Firefox). It's also a bit cheating as your story ultimately ends up with just a template, but it worked for me at least in some simple cases.
@felipe-norato It's not changing the template state because the knob store is not created as the current story is not yet decorated with the
withKnobs
. Here's the workaround I'm using on my project to have a wrapper with a specific class attribute around all components. This way, I have a knob on all the stories and I can easily change the displayed theme.In theme-decorator.js:
import { withKnobs, select } from '@storybook/addon-knobs'; import { themes, DEFAULT_THEME } from './themes'; export const withTheme = (story, context) => { const storyWithKnobs = withKnobs(story, context); return { ...storyWithKnobs, props: { ...storyWithKnobs.props, ...{ theme: select('Theme', themes, DEFAULT_THEME) } }, template: `<div data-theme-decorator [ngClass]="theme">${storyWithKnobs.template}</div>` }; };
In config.js:
import { withTheme } from './theme-decorator'; addDecorator(withTheme);
@fvilers what do you have on the ./themes i wonder?
@w2kzzz
export const DEFAULT_THEME = '';
export const themes = {
"Theme 1": 'class_name_1',
"Theme 2": 'class_name_2',
};
Hello @jonrimmer !
I know it's a bit late but are you looking for something like this?
export default {
title: 'My title',
decorators: [
story => ({
...story(),
template: `<div class="my-wrapping-class">${story().template}</div>`
})
]
};
You might be able to use another component to wrap the template instead of the div above, if your wrapping component is already available in your story moduleMetadata or by adding it manually inside your decorator function, using the spread operator, like so (to be adapted):
export default {
title: 'My title',
decorators: [
story => ({
...story(),
template: `<my-wrapping-component>${story().template}</my-wrapping-component>`,
moduleMetadata: {
...story().moduleMetadata,
declarations: [
...story().moduleMetadata.declarations,
MyWrappingComponent
]
}
})
]
};
If you want to reuse this decorator elsewhere you can of course export it in another file for later use:
export const pageDecorator = story => ({...})
And use it like so :
import { pageDecorator } from '../decorators/page.decorator';
export default {
title: 'My title',
decorators: [ pageDecorator ]
};
Hi @clmntr thanks for the tip. I gave this a go but I don't have a .template
property on the object returned by story()
:
export default {
title: 'My component',
decorators: [
story => {
console.log('Story object:', story()); // object does not have a template field
return story();
}
]
};
Anything I've missed? I'm using Storybook Angular 5.3.17.
Hi @markchitty
Right, as is, this should work only if you provide a template to your story:
export default {
title: 'My title',
decorators: [
story => ({
...story(),
template: `<div class="my-wrapping-class">${story().template}</div>`
})
]
};
export const myComponentDefaultStory = () => ({
component: MyComponent,
moduleMetadata: {...}
template: `<my-component [label]="label"></my-component>`,
props: {
label: 'property'
}
});
Might need to be adapted though :)
Hope it helps!
Ah great, thanks. I hadn't clocked that you can use the template
property in addition to or instead of the component
property on the story definition.
Most helpful comment
@matthewharwood My idea of using a template function didn't really work out, as it seemed interact badly with addons like knobs.
However, you can add a small helper function to your app that will let you easily create simple template-wrapping decorators in the manner @igor-dv suggested:
This can then be used as follows: