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

feat: add scoped slots option #507

Merged
merged 22 commits into from
Apr 14, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Vue Test Utils is the official unit testing utility library for Vue.js.
* [Mounting Options](api/options.md)
- [context](api/options.md#context)
- [slots](api/options.md#slots)
- [scopedSlots](api/options.md#scopedslots)
- [stubs](api/options.md#stubs)
- [mocks](api/options.md#mocks)
- [localVue](api/options.md#localvue)
Expand Down
1 change: 1 addition & 0 deletions docs/en/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* [Mounting Options](api/options.md)
- [context](api/options.md#context)
- [slots](api/options.md#slots)
- [scopedSlots](api/options.md#scopedslots)
- [stubs](api/options.md#stubs)
- [mocks](api/options.md#mocks)
- [localVue](api/options.md#localvue)
Expand Down
1 change: 1 addition & 0 deletions docs/en/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* [Mounting Options](./options.md)
- [context](./options.md#context)
- [slots](./options.md#slots)
- [scopedSlots](./options.md#scopedslots)
- [stubs](./options.md#stubs)
- [mocks](./options.md#mocks)
- [localVue](./options.md#localvue)
Expand Down
29 changes: 28 additions & 1 deletion docs/en/api/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Options for `mount` and `shallow`. The options object can contain both Vue Test

- [`context`](#context)
- [`slots`](#slots)
- [`scopedSlots`](#scopedslots)
- [`stubs`](#stubs)
- [`mocks`](#mocks)
- [`localVue`](#localvue)
Expand Down Expand Up @@ -66,7 +67,33 @@ You can pass text to `slots`.
There is a limitation to this.

This does not support PhantomJS.
Please use [Puppeteer](https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer).
You can use [Puppeteer](https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer) as an alternative.

### `scopedSlots`

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

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

There are three limitations.

* This option is only supported in [email protected]+.

* You can not use `<template>` tag as the root element in the `scopedSlots` option.

* This does not support PhantomJS.
You can use [Puppeteer](https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer) as an alternative.

Example:

```js
const wrapper = shallow(Component, {
scopedSlots: {
foo: '<p slot-scope="props">{{props.index}},{{props.text}}</p>'
}
})
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
1 change: 1 addition & 0 deletions flow/options.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare type Options = { // eslint-disable-line no-undef
attachToDocument?: boolean,
mocks?: Object,
slots?: Object,
scopedSlots?: Object,
localVue?: Component,
provide?: Object,
stubs?: Object,
Expand Down
17 changes: 17 additions & 0 deletions packages/create-instance/add-scoped-slots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// @flow

import { compileToFunctions } from 'vue-template-compiler'
import { throwError } from 'shared/util'

export function addScopedSlots (vm: Component, scopedSlots: Object): void {
Object.keys(scopedSlots).forEach((key) => {
const template = scopedSlots[key].trim()
if (template.substr(0, 9) === '<template') {
throwError('the scopedSlots option does not support a template tag as the root element.')
}
const domParser = new window.DOMParser()
const _document = domParser.parseFromString(template, 'text/html')
vm.$_vueTestUtils_scopedSlots[key] = compileToFunctions(template).render
vm.$_vueTestUtils_slotScopes[key] = _document.body.firstChild.getAttribute('slot-scope')
})
}
6 changes: 3 additions & 3 deletions packages/create-instance/add-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ function addSlotToVm (vm: Component, slotName: string, slotValue: Component | st
throwError('vueTemplateCompiler is undefined, you must pass components explicitly if vue-template-compiler is undefined')
}
if (window.navigator.userAgent.match(/PhantomJS/i)) {
throwError('option.slots does not support strings in PhantomJS. Please use Puppeteer, or pass a component')
throwError('the slots option does not support strings in PhantomJS. Please use Puppeteer, or pass a component.')
}
const domParser = new window.DOMParser()
const document = domParser.parseFromString(slotValue, 'text/html')
const _document = domParser.parseFromString(slotValue, 'text/html')
const _slotValue = slotValue.trim()
if (_slotValue[0] === '<' && _slotValue[_slotValue.length - 1] === '>' && document.body.childElementCount === 1) {
if (_slotValue[0] === '<' && _slotValue[_slotValue.length - 1] === '>' && _document.body.childElementCount === 1) {
elem = vm.$createElement(compileToFunctions(slotValue))
} else {
const compiledResult = compileToFunctions(`<div>${slotValue}{{ }}</div>`)
Expand Down
36 changes: 36 additions & 0 deletions packages/create-instance/create-instance.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @flow

import Vue from 'vue'
import { addSlots } from './add-slots'
import { addScopedSlots } from './add-scoped-slots'
import addMocks from './add-mocks'
import addAttrs from './add-attrs'
import addListeners from './add-listeners'
Expand Down Expand Up @@ -57,6 +59,40 @@ export default function createInstance (
addAttrs(vm, options.attrs)
addListeners(vm, options.listeners)

if (options.scopedSlots) {
if (window.navigator.userAgent.match(/PhantomJS/i)) {
throwError('the scopedSlots option does not support PhantomJS. Please use Puppeteer, or pass a component.')
}
const vueVersion = Number(`${Vue.version.split('.')[0]}.${Vue.version.split('.')[1]}`)
if (vueVersion >= 2.5) {
vm.$_vueTestUtils_scopedSlots = {}
vm.$_vueTestUtils_slotScopes = {}
const renderSlot = vm._renderProxy._t

vm._renderProxy._t = function (name, feedback, props, bindObject) {
const scopedSlotFn = vm.$_vueTestUtils_scopedSlots[name]
const slotScope = vm.$_vueTestUtils_slotScopes[name]
if (scopedSlotFn) {
props = { ...bindObject, ...props }
const proxy = {}
const helpers = ['_c', '_o', '_n', '_s', '_l', '_t', '_q', '_i', '_m', '_f', '_k', '_b', '_v', '_e', '_u', '_g']
helpers.forEach((key) => {
proxy[key] = vm._renderProxy[key]
})
proxy[slotScope] = props
Copy link
Member

Choose a reason for hiding this comment

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

Does Vue throw an error if there are two conflicting slot scopes? If not, we should throw an error here

Copy link
Contributor Author

@38elements 38elements Apr 14, 2018

Choose a reason for hiding this comment

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

It is impossible to set a word that starts with "_" in Vue.

[Vue warn]: Property or method "_foo" is not defined on the instance but referenced during render. 
Make sure that this property is reactive, either in the data option, or for class-based components, by initializing the property. 
See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.

Copy link
Member

Choose a reason for hiding this comment

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

I meant if you mounted with this option:

{
scopedSlots: {
  foo: '<div />',
  foo: '<input />'
}
}

The second scopedSlot would overwrite the first scopedSlots in _vueTestUtils_slotScopes. Although thinking about it, this is probably fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is not possible to register the same key.

{ foo: 1, foo: 2 }
// => { foo: 2 }

Copy link
Member

Choose a reason for hiding this comment

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

🤦‍♂️ Of course ;p

return scopedSlotFn.call(proxy)
} else {
return renderSlot.call(vm._renderProxy, name, feedback, props, bindObject)
}
}

// $FlowIgnore
addScopedSlots(vm, options.scopedSlots)
} else {
throwError('the scopedSlots option is only supported in [email protected]+.')
}
}

if (options.slots) {
addSlots(vm, options.slots)
}
Expand Down
1 change: 1 addition & 0 deletions packages/server-test-utils/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface MountOptions<V extends Vue> extends ComponentOptions<V> {
localVue?: typeof Vue
mocks?: object
slots?: Slots
scopedSlots?: Record<string, string>
stubs?: Stubs,
attrs?: object
listeners?: object
Expand Down
1 change: 1 addition & 0 deletions packages/test-utils/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ interface MountOptions<V extends Vue> extends ComponentOptions<V> {
localVue?: typeof Vue
mocks?: object
slots?: Slots
scopedSlots?: Record<string, string>
stubs?: Stubs,
attrs?: object
listeners?: object
Expand Down
3 changes: 3 additions & 0 deletions packages/test-utils/types/test/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ mount(ClassComponent, {
foo: [normalOptions, functionalOptions],
bar: ClassComponent
},
scopedSlots: {
baz: `<div>Baz</div>`
},
stubs: {
foo: normalOptions,
bar: functionalOptions,
Expand Down
33 changes: 33 additions & 0 deletions test/resources/components/component-with-scoped-slots.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<template>
<div>
<div id="foo">
<slot name="foo"
v-for="(item, index) in foo"
:text="item.text"
:index="index">
</slot>
</div>
<div id="bar">
<slot name="bar"
v-for="(item, index) in bar"
:text="item.text"
:index="index">
</slot>
</div>
<div id="slots">
<slot></slot>
</div>
</div>
</template>

<script>
export default {
name: 'component-with-scoped-slots',
data () {
return {
foo: [{ text: 'a1' }, { text: 'a2' }, { text: 'a3' }],
bar: [{ text: 'A1' }, { text: 'A2' }, { text: 'A3' }]
}
}
}
</script>
81 changes: 81 additions & 0 deletions test/specs/mounting-options/scopedSlots.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describeWithShallowAndMount, vueVersion, itDoNotRunIf } from '~resources/utils'
import ComponentWithScopedSlots from '~resources/components/component-with-scoped-slots.vue'

describeWithShallowAndMount('scopedSlots', (mountingMethod) => {
let _window

beforeEach(() => {
_window = window
})

afterEach(() => {
if (!window.navigator.userAgent.match(/Chrome/i)) {
window = _window // eslint-disable-line no-native-reassign
}
})

itDoNotRunIf(vueVersion < 2.5,
'mounts component scoped slots', () => {
const wrapper = mountingMethod(ComponentWithScopedSlots, {
slots: { default: '<span>123</span>' },
scopedSlots: {
'foo': '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>',
'bar': '<p slot-scope="bar">{{bar.text}},{{bar.index}}</p>'
}
})
expect(wrapper.find('#slots').html()).to.equal('<div id="slots"><span>123</span></div>')
expect(wrapper.find('#foo').html()).to.equal('<div id="foo"><p>0,a1</p><p>1,a2</p><p>2,a3</p></div>')
expect(wrapper.find('#bar').html()).to.equal('<div id="bar"><p>A1,0</p><p>A2,1</p><p>A3,2</p></div>')
wrapper.vm.foo = [{ text: 'b1' }, { text: 'b2' }, { text: 'b3' }]
wrapper.vm.bar = [{ text: 'B1' }, { text: 'B2' }, { text: 'B3' }]
expect(wrapper.find('#foo').html()).to.equal('<div id="foo"><p>0,b1</p><p>1,b2</p><p>2,b3</p></div>')
expect(wrapper.find('#bar').html()).to.equal('<div id="bar"><p>B1,0</p><p>B2,1</p><p>B3,2</p></div>')
}
)

itDoNotRunIf(vueVersion < 2.5,
'throws exception when it is seted to a template tag at top', () => {
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
'foo': '<template></template>'
}
})
}
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)
}
)

itDoNotRunIf(vueVersion >= 2.5,
'throws exception when vue version < 2.5', () => {
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
'foo': '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>'
}
})
}
const message = '[vue-test-utils]: the scopedSlots option is only supported in [email protected]+.'
expect(fn).to.throw().with.property('message', message)
}
)

itDoNotRunIf(vueVersion < 2.5,
'throws exception when using PhantomJS', () => {
if (window.navigator.userAgent.match(/Chrome/i)) {
return
}
window = { navigator: { userAgent: 'PhantomJS' }} // eslint-disable-line no-native-reassign
const fn = () => {
mountingMethod(ComponentWithScopedSlots, {
scopedSlots: {
'foo': '<p slot-scope="foo">{{foo.index}},{{foo.text}}</p>'
}
})
}
const message = '[vue-test-utils]: the scopedSlots option does not support PhantomJS. Please use Puppeteer, or pass a component.'
expect(fn).to.throw().with.property('message', message)
}
)
})
2 changes: 1 addition & 1 deletion test/specs/mounting-options/slots.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describeWithMountingMethods('options.slots', (mountingMethod) => {
return
}
window = { navigator: { userAgent: 'PhantomJS' }} // eslint-disable-line no-native-reassign
const message = '[vue-test-utils]: option.slots does not support strings in PhantomJS. Please use Puppeteer, or pass a component'
const message = '[vue-test-utils]: the slots option does not support strings in PhantomJS. Please use Puppeteer, or pass a component.'
const fn = () => mountingMethod(ComponentWithSlots, { slots: { default: 'foo' }})
expect(fn).to.throw().with.property('message', message)
})
Expand Down