Merge pull request #5057 from backbone87/vue

improve vue integration
This commit is contained in:
Norbert de Langen 2018-12-20 21:24:22 +01:00 committed by GitHub
commit 7e9621af6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 186 additions and 132 deletions

View File

@ -2,12 +2,15 @@
import Vue from 'vue'; import Vue from 'vue';
function getRenderedTree(story, context) { function getRenderedTree(story, context) {
const storyElement = story.render(context); const component = story.render(context);
const Constructor = Vue.extend(storyElement); const vm = new Vue({
const vm = new Constructor().$mount(); render(h) {
return h(component);
},
});
return vm.$el; return vm.$mount().$el;
} }
export default getRenderedTree; export default getRenderedTree;

View File

@ -29,3 +29,20 @@ You can also build a [static version](https://storybook.js.org/basics/exporting-
## Vue Notes ## Vue Notes
- When using global custom components or extension (e.g `Vue.use`). You will need to declare those in the `./storybook/config.js`. - When using global custom components or extension (e.g `Vue.use`). You will need to declare those in the `./storybook/config.js`.
## Known Limitations
In Storybook story and decorator components you can not access the Vue instance
in factory functions for default prop values:
```js
{
props: {
foo: {
default() {
return this.bar; // does not work!
}
}
}
}
```

View File

@ -1,30 +1,66 @@
import { start } from '@storybook/core/client'; import { start } from '@storybook/core/client';
import Vue from 'vue';
import './globals'; import './globals';
import render from './render'; import render, { VALUES } from './render';
import { extractProps } from './util';
const createWrapperComponent = Target => ({ export const WRAPS = 'STORYBOOK_WRAPS';
functional: true,
render(h, c) {
return h(Target, c.data, c.children);
},
});
const decorateStory = (getStory, decorators) =>
decorators.reduce(
(decorated, decorator) => context => {
const story = () => decorated(context);
let decoratedStory = decorator(story, context);
if (typeof decoratedStory === 'string') { function prepare(rawStory, innerStory) {
decoratedStory = { template: decoratedStory }; let story = rawStory;
// eslint-disable-next-line no-underscore-dangle
if (!story._isVue) {
if (typeof story === 'string') {
story = { template: story };
}
if (innerStory) {
story.components = { ...(story.components || {}), story: innerStory };
}
story = Vue.extend(story);
} else if (story.options[WRAPS]) {
return story;
} }
decoratedStory.components = decoratedStory.components || {}; return Vue.extend({
decoratedStory.components.story = createWrapperComponent(story()); [WRAPS]: story,
return decoratedStory; [VALUES]: { ...(innerStory ? innerStory.options[VALUES] : {}), ...extractProps(story) },
functional: true,
render(h, { data, parent, children }) {
return h(
story,
{
...data,
props: { ...(data.props || {}), ...parent.$root[VALUES] },
}, },
getStory children
); );
},
});
}
function decorateStory(getStory, decorators) {
return decorators.reduce(
(decorated, decorator) => context => {
let story;
const decoratedStory = decorator(() => {
story = decorated(context);
return story;
}, context);
if (!story) {
story = decorated(context);
}
if (decoratedStory === story) {
return story;
}
return prepare(decoratedStory, story);
},
context => prepare(getStory(context))
);
}
const { clientApi, configApi, forceReRender } = start(render, { decorateStory }); const { clientApi, configApi, forceReRender } = start(render, { decorateStory });

View File

@ -1,27 +1,21 @@
import { stripIndents } from 'common-tags'; import { stripIndents } from 'common-tags';
import Vue from 'vue'; import Vue from 'vue';
let root = null; export const COMPONENT = 'STORYBOOK_COMPONENT';
export const VALUES = 'STORYBOOK_VALUES';
function getComponentProxy(component) { const root = new Vue({
return Object.entries(component.props || {}) data() {
.map(([name, def]) => ({ [name]: def.default })) return {
.reduce((wrap, prop) => ({ ...wrap, ...prop }), {}); [COMPONENT]: undefined,
} [VALUES]: {},
};
function renderRoot(component, proxy) {
root = new Vue({
el: '#root',
beforeCreate() {
this.proxy = proxy;
}, },
render(h) { render(h) {
const props = this.proxy; const children = this[COMPONENT] ? [h(this[COMPONENT])] : undefined;
return h('div', { attrs: { id: 'root' } }, [h(component, { props })]); return h('div', { attrs: { id: 'root' } }, children);
}, },
}); });
}
export default function render({ export default function render({
story, story,
@ -49,15 +43,14 @@ export default function render({
showMain(); showMain();
const proxy = getComponentProxy(component);
// at component creation || refresh by HMR // at component creation || refresh by HMR
if (!root || !forceRender) { if (!root[COMPONENT] || !forceRender) {
if (root) root.$destroy(); root[COMPONENT] = component;
}
renderRoot(component, proxy); root[VALUES] = component.options[VALUES];
} else {
root.proxy = proxy; if (!root.$el) {
root.$forceUpdate(); root.$mount('#root');
} }
} }

View File

@ -0,0 +1,20 @@
function getType(fn) {
const match = fn && fn.toString().match(/^\s*function (\w+)/);
return match ? match[1] : '';
}
// https://github.com/vuejs/vue/blob/dev/src/core/util/props.js#L92
function resolveDefault({ type, default: def }) {
if (typeof def === 'function' && getType(type) !== 'Function') {
// known limitation: we dont have the component instance to pass
return def.call();
}
return def;
}
export function extractProps(component) {
return Object.entries(component.options.props || {})
.map(([name, prop]) => ({ [name]: resolveDefault(prop) }))
.reduce((wrap, prop) => ({ ...wrap, ...prop }), {});
}

View File

@ -1,18 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots Addon|Centered rounded 1`] = ` exports[`Storyshots Addon|Centered rounded 1`] = `
<div
style="position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; display: flex; align-items: center; overflow: auto;"
>
<div
style="margin: auto; max-height: 100%;"
>
<div
style="position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; display: flex; align-items: center; overflow: auto;"
>
<div
style="margin: auto; max-height: 100%;"
>
<div <div
style="position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; display: flex; align-items: center; overflow: auto;" style="position: fixed; top: 0px; left: 0px; bottom: 0px; right: 0px; display: flex; align-items: center; overflow: auto;"
> >
@ -27,8 +15,4 @@ exports[`Storyshots Addon|Centered rounded 1`] = `
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div>
`; `;

View File

@ -2,7 +2,7 @@
exports[`Storyshots Addon|Knobs All knobs 1`] = ` exports[`Storyshots Addon|Knobs All knobs 1`] = `
<div <div
style="padding: 8px 22px; border-radius: 8px;" style="border: 2px dotted; padding: 8px 22px; border-radius: 8px;"
> >
<h1> <h1>
My name is Jane, My name is Jane,
@ -46,8 +46,6 @@ exports[`Storyshots Addon|Knobs Simple 1`] = `
exports[`Storyshots Addon|Knobs XSS safety 1`] = ` exports[`Storyshots Addon|Knobs XSS safety 1`] = `
<div> <div>
&lt;img src=x onerror="alert('XSS Attack')" &gt; &lt;img src=x onerror="alert('XSS Attack')" &gt;
</div> </div>
`; `;

View File

@ -1,12 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Storyshots Custom|Decorator for Vue render 1`] = ` exports[`Storyshots Custom|Decorator for Vue render 1`] = `
<div
style="border: medium solid blue;"
>
<div
style="border: medium solid blue;"
>
<div <div
style="border: medium solid blue;" style="border: medium solid blue;"
> >
@ -20,17 +14,9 @@ exports[`Storyshots Custom|Decorator for Vue render 1`] = `
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
`; `;
exports[`Storyshots Custom|Decorator for Vue template 1`] = ` exports[`Storyshots Custom|Decorator for Vue template 1`] = `
<div
style="border: medium solid blue;"
>
<div
style="border: medium solid blue;"
>
<div <div
style="border: medium solid blue;" style="border: medium solid blue;"
> >
@ -45,6 +31,4 @@ exports[`Storyshots Custom|Decorator for Vue template 1`] = `
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
`; `;

View File

@ -38,53 +38,72 @@ storiesOf('Addon|Knobs', module)
}, },
})) }))
.add('All knobs', () => { .add('All knobs', () => {
const name = text('Name', 'Jane');
const stock = number('Stock', 20, {
range: true,
min: 0,
max: 30,
step: 5,
});
const fruits = { const fruits = {
Apple: 'apples', Apple: 'apples',
Banana: 'bananas', Banana: 'bananas',
Cherry: 'cherries', Cherry: 'cherries',
}; };
const fruit = select('Fruit', fruits, 'apples');
const price = number('Price', 2.25);
const colour = color('Border', 'deeppink');
const today = date('Today', new Date('Jan 20 2017 GMT+0'));
const items = array('Items', ['Laptop', 'Book', 'Whiskey']);
const nice = boolean('Nice', true);
const stockMessage = stock
? `I have a stock of ${stock} ${fruit}, costing &dollar;${price} each.`
: `I'm out of ${fruit}${nice ? ', Sorry!' : '.'}`;
const salutation = nice ? 'Nice to meet you!' : 'Leave me alone!';
const dateOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' };
button('Arbitrary action', action('You clicked it!')); button('Arbitrary action', action('You clicked it!'));
return { return {
props: {
name: { default: text('Name', 'Jane') },
stock: {
default: number('Stock', 20, {
range: true,
min: 0,
max: 30,
step: 5,
}),
},
fruit: { default: select('Fruit', fruits, 'apples') },
price: { default: number('Price', 2.25) },
colour: { default: color('Border', 'deeppink') },
today: { default: date('Today', new Date('Jan 20 2017 GMT+0')) },
// this is necessary, because we cant use arrays/objects directly in vue prop default values
// a factory function is required, but we need to make sure the knob is only called once
items: { default: (items => () => items)(array('Items', ['Laptop', 'Book', 'Whiskey'])) },
nice: { default: boolean('Nice', true) },
},
data: () => ({
dateOptions: { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' },
}),
computed: {
stockMessage() {
return this.stock
? `I have a stock of ${this.stock} ${this.fruit}, costing $${this.price} each.`
: `I'm out of ${this.fruit}${this.nice ? ', Sorry!' : '.'}`;
},
salutation() {
return this.nice ? 'Nice to meet you!' : 'Leave me alone!';
},
formattedDate() {
return new Date(this.today).toLocaleDateString('en-US', this.dateOptions);
},
style() {
return {
'border-color': this.colour,
};
},
},
template: ` template: `
<div style="border:2px dotted ${colour}; padding: 8px 22px; border-radius: 8px"> <div style="border: 2px dotted; padding: 8px 22px; border-radius: 8px" :style="style">
<h1>My name is ${name},</h1> <h1>My name is {{ name }},</h1>
<h3>today is ${new Date(today).toLocaleDateString('en-US', dateOptions)}</h3> <h3>today is {{ formattedDate }}</h3>
<p>${stockMessage}</p> <p>{{ stockMessage }}</p>
<p>Also, I have:</p> <p>Also, I have:</p>
<ul> <ul>
${items.map(item => `<li key=${item}>${item}</li>`).join('')} <li v-for="item in items" :key="item">{{ item }}</li>
</ul> </ul>
<p>${salutation}</p> <p>{{ salutation }}</p>
</div> </div>
`, `,
}; };
}) })
.add('XSS safety', () => ({ .add('XSS safety', () => ({
template: ` props: {
<div> text: { default: text('Rendered string', '<img src=x onerror="alert(\'XSS Attack\')" >') },
${text('Rendered string', '<img src=x onerror="alert(\'XSS Attack\')" >')} },
</div> template: '<div v-html="text"></div>',
`,
})); }));