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

Web-components: Dynamic source snippets #15337

Merged
merged 12 commits into from
Jul 10, 2021
Merged
29 changes: 0 additions & 29 deletions addons/docs/src/frameworks/web-components/config.js

This file was deleted.

19 changes: 19 additions & 0 deletions addons/docs/src/frameworks/web-components/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { extractArgTypes, extractComponentDescription } from './custom-elements';
import { sourceDecorator } from './sourceDecorator';
import { prepareForInline } from './prepareForInline';
import { SourceType } from '../../shared';

export const decorators = [sourceDecorator];

export const parameters = {
docs: {
extractArgTypes,
extractComponentDescription,
inlineStories: true,
prepareForInline,
source: {
type: SourceType.DYNAMIC,
language: 'html',
},
},
};
19 changes: 19 additions & 0 deletions addons/docs/src/frameworks/web-components/prepareForInline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { StoryFn } from '@storybook/addons';
import React from 'react';
import { render } from 'lit-html';

export const prepareForInline = (storyFn: StoryFn) => {
class Story extends React.Component {
wrapperRef = React.createRef<HTMLElement>();

componentDidMount(): void {
render(storyFn(), this.wrapperRef.current);
}

render(): React.ReactElement {
return React.createElement('div', { ref: this.wrapperRef });
}
}

return (React.createElement(Story) as unknown) as React.CElement<{}, React.Component>;
};
73 changes: 73 additions & 0 deletions addons/docs/src/frameworks/web-components/sourceDecorator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { html } from 'lit-html';
import { addons, StoryContext } from '@storybook/addons';
import { sourceDecorator } from './sourceDecorator';
import { SNIPPET_RENDERED } from '../../shared';

jest.mock('@storybook/addons');
const mockedAddons = addons as jest.Mocked<typeof addons>;

expect.addSnapshotSerializer({
print: (val: any) => val,
test: (val) => typeof val === 'string',
});

const makeContext = (name: string, parameters: any, args: any, extra?: object): StoryContext => ({
id: `lit-test--${name}`,
kind: 'js-text',
name,
parameters,
args,
argTypes: {},
globals: {},
...extra,
});

describe('sourceDecorator', () => {
let mockChannel: { on: jest.Mock; emit?: jest.Mock };
beforeEach(() => {
mockedAddons.getChannel.mockReset();

mockChannel = { on: jest.fn(), emit: jest.fn() };
mockedAddons.getChannel.mockReturnValue(mockChannel as any);
});

it('should render dynamically for args stories', () => {
const storyFn = (args: any) => html`<div>args story</div>`;
const context = makeContext('args', { __isArgsStory: true }, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'lit-test--args',
'<div>args story</div>'
);
});

it('should skip dynamic rendering for no-args stories', () => {
const storyFn = () => html`<div>classic story</div>`;
const context = makeContext('classic', {}, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).not.toHaveBeenCalled();
});

it('allows the snippet output to be modified by transformSource', () => {
const storyFn = (args: any) => html`<div>args story</div>`;
const transformSource = (dom: string) => `<p>${dom}</p>`;
const docs = { transformSource };
const context = makeContext('args', { __isArgsStory: true, docs }, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'lit-test--args',
'<p><div>args story</div></p>'
);
});

it('provides the story context to transformSource', () => {
const storyFn = (args: any) => html`<div>args story</div>`;
const transformSource = jest.fn((x) => x);
const docs = { transformSource };
const context = makeContext('args', { __isArgsStory: true, docs }, {});
sourceDecorator(storyFn, context);
expect(transformSource).toHaveBeenCalledWith('<div>args story</div>', context);
});
});
37 changes: 37 additions & 0 deletions addons/docs/src/frameworks/web-components/sourceDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* global window */
import { render } from 'lit-html';
import { addons, StoryContext, StoryFn } from '@storybook/addons';
import { SNIPPET_RENDERED, SourceType } from '../../shared';

function skipSourceRender(context: StoryContext) {
const sourceParams = context?.parameters.docs?.source;
const isArgsStory = context?.parameters.__isArgsStory;

// always render if the user forces it
if (sourceParams?.type === SourceType.DYNAMIC) {
return false;
}

// never render if the user is forcing the block to render code, or
// if the user provides code, or if it's not an args story.
return !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE;
}

function applyTransformSource(source: string, context: StoryContext): string {
const { transformSource } = context.parameters.docs ?? {};
if (typeof transformSource !== 'function') return source;
return transformSource(source, context);
}

export function sourceDecorator(storyFn: StoryFn, context: StoryContext) {
const story = storyFn();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do others feel about context.originalStoryFn here?

Copy link
Member

@shilman shilman Jul 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are my thoughts on the matter: #14652 (comment)

Since this is all brand new in web-components, we can make it opt-out OR opt-in, depending on how you guys feel. I believe it's safer to include the decorators by default and allow users to opt-out of the decorators. In the React world, it's quite common for stories to rely on the decorators to provide context to render properly. But I'll defer to your collective judgment for what's best in the web-components world.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Westbrook can you elaborate on the case, both ways?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I’m less worried as long as it’s configurable. However, for my library I use a very Storybook custom decorator to control theme color/scale/direction in a way that you wouldn’t want to require a copy/paster to clean up after the fact. storyFn() leaves all of the decorator code whereas without it you copy just the component under demo.

Copy link
Member

@shilman shilman Jul 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bennypowers @Westbrook WDYT about adding configuration in this PR? i'm afraid if it doesn't get done now it will slip through the cracks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to put this in i'm just unfamiliar with the relevant apis.

is it just

const story = context.stripDecorators ? context.originalStoryFn() : storyFn(): 

??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make it consistent with React:

const story = context?.parameters.docs?.source?.excludeDecorators
    ? context.originalStoryFn(context.args) 
    : storyFn();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added with unit tests, thanks


if (!skipSourceRender(context)) {
const container = window.document.createElement('div');
render(story, container);
const source = applyTransformSource(container.innerHTML.replace(/<!---->/g, ''), context);
brion-fuller marked this conversation as resolved.
Show resolved Hide resolved
if (source) addons.getChannel().emit(SNIPPET_RENDERED, context.id, source);
}

return story;
}
2 changes: 1 addition & 1 deletion docs/frameworks.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ module.exports = {
},
{
name: 'Dynamic source',
supported: ['react', 'vue', 'angular', 'svelte'],
supported: ['react', 'vue', 'angular', 'svelte', 'web-components'],
path: 'writing-docs/doc-blocks#source',
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';

import { SbButton } from './sb-button';

Expand All @@ -20,9 +21,9 @@ export default {
const Template: Story<SbButton> = ({ primary, backgroundColor, size, label }) =>
html`<sb-button
?primary="${primary}"
.size="${size}"
.label="${label}"
.backgroundColor="${backgroundColor}"
size="${ifDefined(size)}"
label="${ifDefined(label)}"
background-color="${ifDefined(backgroundColor)}"
></sb-button>`;

export const Primary: Story<SbButton> = Template.bind({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class SbButton extends LitElement {
label: { type: String, reflect: true },
primary: { type: Boolean },
size: { type: String },
backgroundColor: { type: String },
backgroundColor: { type: String, attribute: 'background-color' },
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
import { html } from 'lit-html';

<Meta
title="Addons / Docs / Dynamic Source"
component="sb-button"
argTypes={{
size: { type: 'select', options: ['large', 'small'] },
label: { type: 'string' },
primary: { type: 'boolean' },
backgroundColor: { type: 'color', presetColors: ['white', 'transparent', 'blue'] },
}}
/>

# Dynamic Source

Stories can use Dynamic Source to display the result of changes to controls.

<Canvas withSource="open">
<Story
name="Button"
component="sb-button"
args={{
size: 'large',
label: 'Button',
primary: false,
backgroundColor: undefined,
}}
>
{(args) => html`
<sb-button
?primary="${args.primary}"
.size="${args.size}"
.label="${args.label}"
.backgroundColor="${args.backgroundColor}"
>
</sb-button>
`}
</Story>
</Canvas>

<ArgsTable story="Button" />
Loading