Skip to content

Commit

Permalink
feat: add support for JSX scopedSlots value (#871)
Browse files Browse the repository at this point in the history
  • Loading branch information
eddyerburgh committed Jul 29, 2018
1 parent d5fceb7 commit 13bcaeb
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 99 deletions.
2 changes: 1 addition & 1 deletion .babelrc
@@ -1,5 +1,5 @@
{
"presets": ["env", "stage-2", "flow-vue"],
"plugins": ["transform-decorators-legacy"],
"plugins": ["transform-decorators-legacy", "transform-vue-jsx"],
"comments": false
}
48 changes: 35 additions & 13 deletions docs/api/options.md
Expand Up @@ -69,30 +69,52 @@ expect(wrapper.find('div')).toBe(true)

## scopedSlots

- type: `{ [name: string]: string }`
- type: `{ [name: string]: string|Function }`

Provide an object of scoped slots contents to the component. The key corresponds to the slot name. The value can be a template string.
Provide an object of scoped slots to the component. The key corresponds to the slot name.

There are three limitations.
You can set the name of the props using the slot-scope attribute:

* This option is only supported in vue@2.5+.
```js
shallowMount(Component, {
scopedSlots: {
foo: '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>'
}
})
```

* You can not use `<template>` tag as the root element in the `scopedSlots` option.
Otherwise props are available as a `props` object when the slot is evaluated:

* This does not support PhantomJS.
You can use [Puppeteer](https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer) as an alternative.
```js
shallowMount(Component, {
scopedSlots: {
default: '<p>{{props.index}},{{props.text}}</p>'
}
})
```

Example:
You can also pass a function that takes the props as an argument:

```js
const wrapper = shallowMount(Component, {
shallowMount(Component, {
scopedSlots: {
foo: function (props) {
return this.$createElement('div', props.index)
}
}
})
```

Or you can use JSX. If write JSX in a method, `this.$createElement` is auto-injected by babel-plugin-transform-vue-jsx:

```js
shallowMount(Component, {
scopedSlots: {
foo: '<p slot-scope="props">{{props.index}},{{props.text}}</p>'
foo (props) {
return <div>{ props.text }</div>
}
}
})
expect(wrapper.find('#fooWrapper').html()).toBe(
`<div id="fooWrapper"><p>0,text1</p><p>1,text2</p><p>2,text3</p></div>`
)
```

## stubs
Expand Down
2 changes: 1 addition & 1 deletion flow/options.flow.js
Expand Up @@ -5,7 +5,7 @@ declare type Options = {
mocks?: Object,
methods?: { [key: string]: Function },
slots?: SlotsObject,
scopedSlots?: { [key: string]: string },
scopedSlots?: { [key: string]: string | Function },
localVue?: Component,
provide?: Object,
stubs?: Stubs,
Expand Down
8 changes: 5 additions & 3 deletions package.json
Expand Up @@ -31,7 +31,9 @@
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-loader": "^7.1.3",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"babel-polyfill": "^6.23.0",
"babel-preset-env": "^1.6.0",
"babel-preset-flow-vue": "^1.0.0",
Expand Down Expand Up @@ -61,12 +63,12 @@
"rollup": "^0.58.2",
"sinon": "^2.3.2",
"sinon-chai": "^2.10.0",
"vue": "^2.5.16",
"vue": "2.5.16",
"vue-class-component": "^6.1.2",
"vue-loader": "^13.6.2",
"vue-router": "^3.0.1",
"vue-server-renderer": "^2.5.16",
"vue-template-compiler": "^2.5.16",
"vue-server-renderer": "2.5.16",
"vue-template-compiler": "2.5.16",
"vuepress": "^0.10.0",
"vuepress-theme-vue": "^1.0.3",
"vuetify": "^0.16.9",
Expand Down
64 changes: 34 additions & 30 deletions packages/create-instance/create-scoped-slots.js
Expand Up @@ -32,55 +32,59 @@ function getVueTemplateCompilerHelpers (): { [name: string]: Function } {
names.forEach(name => {
helpers[name] = vue._renderProxy[name]
})
helpers.$createElement = vue._renderProxy.$createElement
return helpers
}

function validateEnvironment (): void {
if (window.navigator.userAgent.match(/PhantomJS/i)) {
throwError(
`the scopedSlots option does not support PhantomJS. ` +
`Please use Puppeteer, or pass a component.`
)
}
if (vueVersion < 2.5) {
throwError(`the scopedSlots option is only supported in ` + `vue@2.5+.`)
if (vueVersion < 2.1) {
throwError(`the scopedSlots option is only supported in vue@2.1+.`)
}
}

function validateTempldate (template: string): void {
if (template.trim().substr(0, 9) === '<template') {
throwError(
`the scopedSlots option does not support a template ` +
`tag as the root element.`
)
const slotScopeRe = /<[^>]+ slot-scope=\"(.+)\"/

// Hide warning about <template> disallowed as root element
function customWarn (msg) {
if (msg.indexOf('Cannot use <template> as component root element') === -1) {
console.error(msg)
}
}

export default function createScopedSlots (
scopedSlotsOption: ?{ [slotName: string]: string }
): { [slotName: string]: (props: Object) => VNode | Array<VNode>} {
scopedSlotsOption: ?{ [slotName: string]: string | Function }
): {
[slotName: string]: (props: Object) => VNode | Array<VNode>
} {
const scopedSlots = {}
if (!scopedSlotsOption) {
return scopedSlots
}
validateEnvironment()
const helpers = getVueTemplateCompilerHelpers()
for (const name in scopedSlotsOption) {
const template = scopedSlotsOption[name]
validateTempldate(template)
const render = compileToFunctions(template).render
const domParser = new window.DOMParser()
const _document = domParser.parseFromString(template, 'text/html')
const slotScope = _document.body.firstChild.getAttribute(
'slot-scope'
)
const isDestructuring = isDestructuringSlotScope(slotScope)
scopedSlots[name] = function (props) {
if (isDestructuring) {
return render.call({ ...helpers, ...props })
for (const scopedSlotName in scopedSlotsOption) {
const slot = scopedSlotsOption[scopedSlotName]
const isFn = typeof slot === 'function'
// Type check to silence flow (can't use isFn)
const renderFn = typeof slot === 'function'
? slot
: compileToFunctions(slot, { warn: customWarn }).render

const hasSlotScopeAttr = !isFn && slot.match(slotScopeRe)
const slotScope = hasSlotScopeAttr && hasSlotScopeAttr[1]
scopedSlots[scopedSlotName] = function (props) {
let res
if (isFn) {
res = renderFn.call({ ...helpers }, props)
} else if (slotScope && !isDestructuringSlotScope(slotScope)) {
res = renderFn.call({ ...helpers, [slotScope]: props })
} else if (slotScope && isDestructuringSlotScope(slotScope)) {
res = renderFn.call({ ...helpers, ...props })
} else {
return render.call({ ...helpers, [slotScope]: props })
res = renderFn.call({ ...helpers, props })
}
// res is Array if <template> is a root element
return Array.isArray(res) ? res[0] : res
}
}
return scopedSlots
Expand Down
121 changes: 73 additions & 48 deletions test/specs/mounting-options/scopedSlots.spec.js
@@ -1,7 +1,6 @@
import {
describeWithShallowAndMount,
vueVersion,
isRunningPhantomJS
vueVersion
} from '~resources/utils'
import ComponentWithScopedSlots from '~resources/components/component-with-scoped-slots.vue'
import { itDoNotRunIf } from 'conditional-specs'
Expand All @@ -14,7 +13,41 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
})

itDoNotRunIf(
vueVersion < 2.5 || isRunningPhantomJS,
vueVersion < 2.1,
'handles templates as the root node', () => {
const wrapper = mountingMethod({
template: '<div><slot name="single" :text="foo" :i="123"></slot></div>',
data: () => ({
foo: 'bar'
})
}, {
scopedSlots: {
single: '<template><p>{{props.text}},{{props.i}}</p></template>'
}
})
expect(wrapper.html()).to.equal('<div><p>bar,123</p></div>')
})

itDoNotRunIf(
vueVersion < 2.1,
'handles render functions', () => {
const wrapper = mountingMethod({
template: '<div><slot name="single" :text="foo" /></div>',
data: () => ({
foo: 'bar'
})
}, {
scopedSlots: {
single: function (props) {
return this.$createElement('p', props.text)
}
}
})
expect(wrapper.html()).to.equal('<div><p>bar</p></div>')
})

itDoNotRunIf(
vueVersion < 2.5,
'mounts component scoped slots in render function',
() => {
const destructuringWrapper = mountingMethod(
Expand All @@ -29,7 +62,7 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
{
scopedSlots: {
default:
'<p slot-scope="{ index, item }">{{index}},{{item}}</p>'
'<template slot-scope="{ index, item }"><p>{{index}},{{item}}</p></template>'
}
}
)
Expand All @@ -38,16 +71,16 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
const notDestructuringWrapper = mountingMethod(
{
render: function () {
return this.$scopedSlots.default({
return this.$scopedSlots.named({
index: 1,
item: 'foo'
})
}
},
{
scopedSlots: {
default:
'<p slot-scope="props">{{props.index}},{{props.item}}</p>'
named:
'<p slot-scope="foo">{{foo.index}},{{foo.item}}</p>'
}
}
)
Expand All @@ -56,15 +89,15 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
)

itDoNotRunIf(
vueVersion < 2.5 || isRunningPhantomJS,
vueVersion < 2.5,
'mounts component scoped slots',
() => {
const wrapper = mountingMethod(ComponentWithScopedSlots, {
slots: { default: '<span>123</span>' },
scopedSlots: {
destructuring:
'<p slot-scope="{ index, item }">{{index}},{{item}}</p>',
list: '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>',
list: '<template slot-scope="foo"><p>{{foo.index}},{{foo.text}}</p></template>',
single: '<p slot-scope="bar">{{bar.text}}</p>',
noProps: '<p slot-scope="baz">baz</p>'
}
Expand Down Expand Up @@ -106,51 +139,43 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
)

itDoNotRunIf(
vueVersion < 2.5 || isRunningPhantomJS,
'throws exception when it is seted to a template tag at top',
() => {
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
single: '<template></template>'
}
vueVersion < 2.5,
'handles JSX', () => {
const wrapper = mountingMethod({
template: '<div><slot name="single" :text="foo"></slot></div>',
data: () => ({
foo: 'bar'
})
}
const message =
'[vue-test-utils]: the scopedSlots option does not support a template tag as the root element.'
expect(fn)
.to.throw()
.with.property('message', message)
}
)
}, {
scopedSlots: {
single ({ text }) {
return <p>{ text }</p>
}
}
})
expect(wrapper.html()).to.equal('<div><p>bar</p></div>')
})

itDoNotRunIf(
vueVersion >= 2.5 || isRunningPhantomJS,
'throws exception when vue version < 2.5',
() => {
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
list: '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>'
}
vueVersion < 2.5,
'handles no slot-scope', () => {
const wrapper = mountingMethod({
template: '<div><slot name="single" :text="foo" :i="123"></slot></div>',
data: () => ({
foo: 'bar'
})
}
const message =
'[vue-test-utils]: the scopedSlots option is only supported in vue@2.5+.'
expect(fn)
.to.throw()
.with.property('message', message)
}
)
}, {
scopedSlots: {
single: '<p>{{props.text}},{{props.i}}</p>'
}
})
expect(wrapper.html()).to.equal('<div><p>bar,123</p></div>')
})

itDoNotRunIf(
vueVersion < 2.5,
'throws exception when using PhantomJS',
vueVersion > 2.0,
'throws exception when vue version < 2.1',
() => {
if (window.navigator.userAgent.match(/Chrome|PhantomJS/i)) {
return
}
window = { navigator: { userAgent: 'PhantomJS' }} // eslint-disable-line no-native-reassign
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
Expand All @@ -159,7 +184,7 @@ describeWithShallowAndMount('scopedSlots', mountingMethod => {
})
}
const message =
'[vue-test-utils]: the scopedSlots option does not support PhantomJS. Please use Puppeteer, or pass a component.'
'[vue-test-utils]: the scopedSlots option is only supported in vue@2.1+.'
expect(fn)
.to.throw()
.with.property('message', message)
Expand Down

0 comments on commit 13bcaeb

Please sign in to comment.