Skip to content

Commit 24bfb95

Browse files
matt-oconnelleddyerburgh
authored andcommitted
feat(selector): add refs option (#68)
1 parent a974042 commit 24bfb95

17 files changed

+294
-80
lines changed

flow/wrapper.flow.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type Wrapper from '~src/Wrapper'
44
import type WrapperArray from '~src/WrapperArray'
55

6-
declare type Selector = string | Component
6+
declare type Selector = any
77

88
declare interface BaseWrapper { // eslint-disable-line no-undef
99
at(index: number): Wrapper | void,

src/lib/find-matching-vnodes.js

-38
This file was deleted.

src/lib/find-vnodes-by-ref.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// @flow
2+
3+
import { removeDuplicateNodes, findAllVNodes } from './vnode-utils'
4+
5+
function nodeMatchesRef (node: VNode, refName: string): boolean {
6+
return node.data && node.data.ref === refName
7+
}
8+
9+
export default function findVNodesByRef (vNode: VNode, refName: string): Array<VNode> {
10+
const nodes = findAllVNodes(vNode)
11+
const refFilteredNodes = nodes.filter(node => nodeMatchesRef(node, refName))
12+
// Only return refs defined on top-level VNode to provide the same behavior as selecting via vm.$ref.{someRefName}
13+
const mainVNodeFilteredNodes = refFilteredNodes.filter(node => !!vNode.context.$refs[node.data.ref])
14+
return removeDuplicateNodes(mainVNodeFilteredNodes)
15+
}

src/lib/find-vnodes-by-selector.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// @flow
2+
3+
import { removeDuplicateNodes, findAllVNodes } from './vnode-utils'
4+
5+
function nodeMatchesSelector (node: VNode, selector: string): boolean {
6+
return node.elm && node.elm.getAttribute && node.elm.matches(selector)
7+
}
8+
9+
export default function findVNodesBySelector (vNode: VNode, selector: string): Array<VNode> {
10+
const nodes = findAllVNodes(vNode)
11+
const filteredNodes = nodes.filter(node => nodeMatchesSelector(node, selector))
12+
return removeDuplicateNodes(filteredNodes)
13+
}

src/lib/get-selector-type.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// @flow
2+
3+
import { isDomSelector, isVueComponent, isRefSelector } from './validators.js'
4+
import { throwError } from '../lib/util'
5+
6+
export const selectorTypes = {
7+
DOM_SELECTOR: 'DOM_SELECTOR',
8+
VUE_COMPONENT: 'VUE_COMPONENT',
9+
OPTIONS_OBJECT: 'OPTIONS_OBJECT'
10+
}
11+
12+
function getSelectorType (selector: Selector): string | void {
13+
if (isDomSelector(selector)) {
14+
return selectorTypes.DOM_SELECTOR
15+
}
16+
17+
if (isVueComponent(selector)) {
18+
return selectorTypes.VUE_COMPONENT
19+
}
20+
21+
if (isRefSelector(selector)) {
22+
return selectorTypes.OPTIONS_OBJECT
23+
}
24+
}
25+
26+
export default function getSelectorTypeOrThrow (selector: Selector, methodName: string): string | void {
27+
const selectorType = getSelectorType(selector)
28+
if (!selectorType) {
29+
throwError(`wrapper.${methodName}() must be passed a valid CSS selector, Vue constructor, or valid find option object`)
30+
}
31+
return selectorType
32+
}

src/lib/validators.js

+28-1
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,32 @@ export function isValidSelector (selector: any): boolean {
4343
return true
4444
}
4545

46-
return isVueComponent(selector)
46+
if (isVueComponent(selector)) {
47+
return true
48+
}
49+
50+
return isRefSelector(selector)
51+
}
52+
53+
export function isRefSelector (refOptionsObject: any) {
54+
if (typeof refOptionsObject !== 'object') {
55+
return false
56+
}
57+
58+
if (refOptionsObject === null) {
59+
return false
60+
}
61+
62+
const validFindKeys = ['ref']
63+
const entries = Object.entries(refOptionsObject)
64+
65+
if (!entries.length) {
66+
return false
67+
}
68+
69+
const isValid = entries.every(([key, value]) => {
70+
return validFindKeys.includes(key) && typeof value === 'string'
71+
})
72+
73+
return isValid
4774
}

src/lib/vnode-utils.js

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// @flow
2+
3+
export function findAllVNodes (vnode: VNode, nodes: Array<VNode> = []): Array<VNode> {
4+
nodes.push(vnode)
5+
6+
if (Array.isArray(vnode.children)) {
7+
vnode.children.forEach((childVNode) => {
8+
findAllVNodes(childVNode, nodes)
9+
})
10+
}
11+
12+
if (vnode.child) {
13+
findAllVNodes(vnode.child._vnode, nodes)
14+
}
15+
16+
return nodes
17+
}
18+
19+
export function removeDuplicateNodes (vNodes: Array<VNode>): Array<VNode> {
20+
const uniqueNodes = []
21+
vNodes.forEach((vNode) => {
22+
const exists = uniqueNodes.some(node => vNode.elm === node.elm)
23+
if (!exists) {
24+
uniqueNodes.push(vNode)
25+
}
26+
})
27+
return uniqueNodes
28+
}

src/wrappers/wrapper.js

+50-25
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// @flow
22

33
import Vue from 'vue'
4-
import { isValidSelector } from '../lib/validators'
4+
import getSelectorTypeOrThrow, { selectorTypes } from '../lib/get-selector-type'
55
import findVueComponents, { vmCtorMatchesName } from '../lib/find-vue-components'
6-
import findMatchingVNodes from '../lib/find-matching-vnodes'
6+
import findVNodesBySelector from '../lib/find-vnodes-by-selector'
7+
import findVNodesByRef from '../lib/find-vnodes-by-ref'
78
import VueWrapper from './vue-wrapper'
89
import WrapperArray from './wrapper-array'
910
import ErrorWrapper from './error-wrapper'
@@ -36,16 +37,22 @@ export default class Wrapper implements BaseWrapper {
3637
* Checks if wrapper contains provided selector.
3738
*/
3839
contains (selector: Selector) {
39-
if (!isValidSelector(selector)) {
40-
throwError('wrapper.contains() must be passed a valid CSS selector or a Vue constructor')
41-
}
40+
const selectorType = getSelectorTypeOrThrow(selector, 'contains')
4241

43-
if (typeof selector === 'object') {
42+
if (selectorType === selectorTypes.VUE_COMPONENT) {
4443
const vm = this.vm || this.vnode.context.$root
4544
return findVueComponents(vm, selector.name).length > 0
4645
}
4746

48-
if (typeof selector === 'string' && this.element instanceof HTMLElement) {
47+
if (selectorType === selectorTypes.OPTIONS_OBJECT) {
48+
if (!this.isVueComponent) {
49+
throwError('$ref selectors can only be used on Vue component wrappers')
50+
}
51+
const nodes = findVNodesByRef(this.vnode, selector.ref)
52+
return nodes.length > 0
53+
}
54+
55+
if (selectorType === selectorTypes.DOM_SELECTOR && this.element instanceof HTMLElement) {
4956
return this.element.querySelectorAll(selector).length > 0
5057
}
5158

@@ -174,12 +181,10 @@ export default class Wrapper implements BaseWrapper {
174181
/**
175182
* Finds first node in tree of the current wrapper that matches the provided selector.
176183
*/
177-
find (selector: string): Wrapper | ErrorWrapper | VueWrapper {
178-
if (!isValidSelector(selector)) {
179-
throwError('wrapper.find() must be passed a valid CSS selector or a Vue constructor')
180-
}
184+
find (selector: Selector): Wrapper | ErrorWrapper | VueWrapper {
185+
const selectorType = getSelectorTypeOrThrow(selector, 'find')
181186

182-
if (typeof selector === 'object') {
187+
if (selectorType === selectorTypes.VUE_COMPONENT) {
183188
if (!selector.name) {
184189
throwError('.find() requires component to have a name property')
185190
}
@@ -191,7 +196,18 @@ export default class Wrapper implements BaseWrapper {
191196
return new VueWrapper(components[0], this.options)
192197
}
193198

194-
const nodes = findMatchingVNodes(this.vnode, selector)
199+
if (selectorType === selectorTypes.OPTIONS_OBJECT) {
200+
if (!this.isVueComponent) {
201+
throwError('$ref selectors can only be used on Vue component wrappers')
202+
}
203+
const nodes = findVNodesByRef(this.vnode, selector.ref)
204+
if (nodes.length === 0) {
205+
return new ErrorWrapper(`ref="${selector.ref}"`)
206+
}
207+
return new Wrapper(nodes[0], this.update, this.options)
208+
}
209+
210+
const nodes = findVNodesBySelector(this.vnode, selector)
195211

196212
if (nodes.length === 0) {
197213
return new ErrorWrapper(selector)
@@ -203,11 +219,9 @@ export default class Wrapper implements BaseWrapper {
203219
* Finds node in tree of the current wrapper that matches the provided selector.
204220
*/
205221
findAll (selector: Selector): WrapperArray {
206-
if (!isValidSelector(selector)) {
207-
throwError('wrapper.findAll() must be passed a valid CSS selector or a Vue constructor')
208-
}
222+
const selectorType = getSelectorTypeOrThrow(selector, 'findAll')
209223

210-
if (typeof selector === 'object') {
224+
if (selectorType === selectorTypes.VUE_COMPONENT) {
211225
if (!selector.name) {
212226
throwError('.findAll() requires component to have a name property')
213227
}
@@ -216,11 +230,19 @@ export default class Wrapper implements BaseWrapper {
216230
return new WrapperArray(components.map(component => new VueWrapper(component, this.options)))
217231
}
218232

233+
if (selectorType === selectorTypes.OPTIONS_OBJECT) {
234+
if (!this.isVueComponent) {
235+
throwError('$ref selectors can only be used on Vue component wrappers')
236+
}
237+
const nodes = findVNodesByRef(this.vnode, selector.ref)
238+
return new WrapperArray(nodes.map(node => new Wrapper(node, this.update, this.options)))
239+
}
240+
219241
function nodeMatchesSelector (node, selector) {
220242
return node.elm && node.elm.getAttribute && node.elm.matches(selector)
221243
}
222244

223-
const nodes = findMatchingVNodes(this.vnode, selector)
245+
const nodes = findVNodesBySelector(this.vnode, selector)
224246
const matchingNodes = nodes.filter(node => nodeMatchesSelector(node, selector))
225247

226248
return new WrapperArray(matchingNodes.map(node => new Wrapper(node, this.update, this.options)))
@@ -237,20 +259,23 @@ export default class Wrapper implements BaseWrapper {
237259
* Checks if node matches selector
238260
*/
239261
is (selector: Selector): boolean {
240-
if (!isValidSelector(selector)) {
241-
throwError('wrapper.is() must be passed a valid CSS selector or a Vue constructor')
242-
}
262+
const selectorType = getSelectorTypeOrThrow(selector, 'is')
243263

244-
if (typeof selector === 'object') {
245-
if (!this.isVueComponent) {
246-
return false
247-
}
264+
if (selectorType === selectorTypes.VUE_COMPONENT && this.isVueComponent) {
248265
if (typeof selector.name !== 'string') {
249266
throwError('a Component used as a selector must have a name property')
250267
}
251268
return vmCtorMatchesName(this.vm, selector.name)
252269
}
253270

271+
if (selectorType === selectorTypes.OPTIONS_OBJECT) {
272+
throwError('$ref selectors can not be used with wrapper.is()')
273+
}
274+
275+
if (typeof selector === 'object') {
276+
return false
277+
}
278+
254279
return !!(this.element &&
255280
this.element.getAttribute &&
256281
this.element.matches(selector))

test/resources/components/component-with-child.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<div>
33
<span>
4-
<child-component />
4+
<child-component ref="child"/>
55
</span>
66
</div>
77
</template>

test/resources/components/component-with-v-for.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div>
3-
<AComponent v-for="item in items" :key="item.id" />
3+
<AComponent v-for="item in items" :key="item.id" ref="item"/>
44
</div>
55
</template>
66

test/unit/specs/mount/Wrapper/contains.spec.js

+23-2
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,40 @@ describe('contains', () => {
1515
expect(wrapper.contains(Component)).to.equal(true)
1616
})
1717

18+
it('returns true if wrapper contains element specified by ref selector', () => {
19+
const compiled = compileToFunctions('<div><input ref="foo" /></div>')
20+
const wrapper = mount(compiled)
21+
expect(wrapper.contains({ ref: 'foo' })).to.equal(true)
22+
})
23+
24+
it('throws an error when ref selector is called on a wrapper that is not a Vue component', () => {
25+
const compiled = compileToFunctions('<div><a href="/"></a></div>')
26+
const wrapper = mount(compiled)
27+
const a = wrapper.find('a')
28+
const message = '[vue-test-utils]: $ref selectors can only be used on Vue component wrappers'
29+
const fn = () => a.contains({ ref: 'foo' })
30+
expect(fn).to.throw().with.property('message', message)
31+
})
32+
1833
it('returns false if wrapper does not contain element', () => {
1934
const compiled = compileToFunctions('<div><input /></div>')
2035
const wrapper = mount(compiled)
2136
expect(wrapper.contains('doesntexist')).to.equal(false)
2237
})
2338

39+
it('returns false if wrapper does not contain element specified by ref selector', () => {
40+
const compiled = compileToFunctions('<div><input ref="bar" /></div>')
41+
const wrapper = mount(compiled)
42+
expect(wrapper.contains({ ref: 'foo' })).to.equal(false)
43+
})
44+
2445
it('throws an error if selector is not a valid selector', () => {
2546
const wrapper = mount(Component)
2647
const invalidSelectors = [
27-
undefined, null, NaN, 0, 2, true, false, () => {}, {}, { name: undefined }, []
48+
undefined, null, NaN, 0, 2, true, false, () => {}, {}, { name: undefined }, { ref: 'foo', nope: true }, []
2849
]
2950
invalidSelectors.forEach((invalidSelector) => {
30-
const message = '[vue-test-utils]: wrapper.contains() must be passed a valid CSS selector or a Vue constructor'
51+
const message = '[vue-test-utils]: wrapper.contains() must be passed a valid CSS selector, Vue constructor, or valid find option object'
3152
const fn = () => wrapper.contains(invalidSelector)
3253
expect(fn).to.throw().with.property('message', message)
3354
})

0 commit comments

Comments
 (0)