Skip to content

Commit

Permalink
fix: return abitility to use findComponent with DOM selector (vuejs#994)
Browse files Browse the repository at this point in the history
* fix: return ability to use findComponent with DOM selector

* fix: ensure findAllComponents return at most one vnode for same el
  • Loading branch information
xanf authored Oct 7, 2021
1 parent fa0c8a8 commit 239caf8
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 27 deletions.
31 changes: 31 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ findComponent<T extends ComponentPublicInstance>(selector: FindComponentSelector
| syntax | example | details |
| -------------- | ----------------------------- | ------------------------------------------------------------ |
| querySelector | `findComponent('.component')` | Matches standard query selector. |
| Component name | `findComponent({name: 'a'})` | matches PascalCase, snake-case, camelCase |
| Component ref | `findComponent({ref: 'ref'})` | Can be used only on direct ref children of mounted component |
| SFC | `findComponent(Component)` | Pass an imported component directly |
Expand Down Expand Up @@ -1165,6 +1166,7 @@ test('findComponent', () => {
If `ref` in component points to HTML element, `findComponent` will return empty wrapper. This is intended behaviour
:::
**NOTE** `getComponent` and `findComponent` will not work on functional components, because they do not have an internal Vue instance (this is what makes functional components more performant). That means the following will **not** work:
```js
Expand All @@ -1179,6 +1181,31 @@ wrapper.findComponent(Foo)
For tests using functional component, consider using `get` or `find` and treating them like standard DOM nodes.
:::warning Usage with CSS selectors
Using `findComponent` with CSS selector might have confusing behavior
Consider this example:
```js
const ChildComponent = {
name: 'Child',
template: '<div class="child"></div>'
}
const RootComponent = {
name: 'Root',
components: { ChildComponent },
template: '<child-component class="root" />'
}
const wrapper = mount(RootComponent)
const rootByCss = wrapper.findComponent('.root') // => finds Root
expect(rootByCss.vm.$options.name).toBe('Root')
const childByCss = wrapper.findComponent('.child')
expect(childByCss.vm.$options.name).toBe('Root') // => still Root
```
The reason for such behavior is that `RootComponent` and `ChildComponent` are sharing same DOM node and only first matching component is included for each unique DOM node
:::
### findAllComponents
**Signature:**
Expand Down Expand Up @@ -1219,6 +1246,10 @@ test('findAllComponents', () => {
})
```
:::warning Usage with CSS selectors
`findAllComponents` has same behavior when used with CSS selector as [findComponent](#findcomponent)
:::
### get
Gets an element and returns a `DOMWrapper` if found. Otherwise it throws an error.
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ interface NameSelector {
name: string
}

export type FindComponentSelector = RefSelector | NameSelector
export type FindAllComponentsSelector = NameSelector
export type FindComponentSelector = RefSelector | NameSelector | string
export type FindAllComponentsSelector = NameSelector | string

export type Slot = VNode | string | { render: Function } | Function | Component

Expand Down
14 changes: 10 additions & 4 deletions src/utils/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,14 @@ export function find(
root: VNode,
selector: FindAllComponentsSelector
): ComponentPublicInstance[] {
return findAllVNodes(root, selector).map(
// @ts-ignore
(vnode: VNode) => vnode.component!.proxy!
)
let matchingVNodes = findAllVNodes(root, selector)

if (typeof selector === 'string') {
// When searching by CSS selector we want only one (topmost) vnode for each el`
matchingVNodes = matchingVNodes.filter(
(vnode: VNode) => vnode.component!.parent?.vnode.el !== vnode.el
)
}

return matchingVNodes.map((vnode: VNode) => vnode.component!.proxy!)
}
32 changes: 22 additions & 10 deletions src/vueWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,6 @@ export class VueWrapper<T extends ComponentPublicInstance>
findComponent<T extends ComponentPublicInstance>(
selector: FindComponentSelector | (new () => T)
): VueWrapper<T> {
if (typeof selector === 'string') {
throw Error(
'findComponent requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
)
}

if (typeof selector === 'object' && 'ref' in selector) {
const result = this.vm.$refs[selector.ref]
if (result && !(result instanceof HTMLElement)) {
Expand All @@ -171,13 +165,31 @@ export class VueWrapper<T extends ComponentPublicInstance>
return createWrapperError('VueWrapper')
}

findAllComponents(selector: FindAllComponentsSelector): VueWrapper<any>[] {
getComponent<T extends ComponentPublicInstance>(
selector: FindComponentSelector | (new () => T)
): Omit<VueWrapper<T>, 'exists'> {
const result = this.findComponent(selector)

if (result instanceof VueWrapper) {
return result as VueWrapper<T>
}

let message = 'Unable to get '
if (typeof selector === 'string') {
throw Error(
'findAllComponents requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
)
message += `component with selector ${selector}`
} else if ('name' in selector) {
message += `component with name ${selector.name}`
} else if ('ref' in selector) {
message += `component with ref ${selector.ref}`
} else {
message += 'specified component'
}
message += ` within: ${this.html()}`
throw new Error(message)
}

findAllComponents(selector: FindAllComponentsSelector): VueWrapper<T>[] {
const results = find(this.vm.$.subTree, selector)
return find(this.vm.$.subTree, selector).map((c) => createWrapper(null, c))
}

Expand Down
5 changes: 5 additions & 0 deletions test-dts/getComponent.d-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const componentByName = wrapper.getComponent({ name: 'ComponentToFind' })
// returns a wrapper with a generic vm (any)
expectType<ComponentPublicInstance>(componentByName.vm)

// get by string
const componentByString = wrapper.getComponent('other')
// returns a wrapper with a generic vm (any)
expectType<ComponentPublicInstance>(componentByString.vm)

// get by ref
const componentByRef = wrapper.getComponent({ ref: 'ref' })
// returns a wrapper with a generic vm (any)
Expand Down
2 changes: 1 addition & 1 deletion tests/attributes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('attributes', () => {
}
})

expect(wrapper.findComponent({ name: 'Hello' }).attributes()).toEqual({
expect(wrapper.findComponent('.hello-outside').attributes()).toEqual({
class: 'hello-outside',
'data-testid': 'hello',
disabled: ''
Expand Down
45 changes: 44 additions & 1 deletion tests/findAllComponents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const compA = defineComponent({
describe('findAllComponents', () => {
it('finds all deeply nested vue components', () => {
const wrapper = mount(compA)
expect(wrapper.findAllComponents(compC)).toHaveLength(2)
// find by DOM selector
expect(wrapper.findAllComponents('.C')).toHaveLength(2)
expect(wrapper.findAllComponents({ name: 'Hello' })[0].text()).toBe(
'Hello world'
)
Expand All @@ -35,4 +36,46 @@ describe('findAllComponents', () => {
expect(wrapper.findAllComponents(Hello)).toHaveLength(3)
expect(wrapper.find('.nested').findAllComponents(Hello)).toHaveLength(2)
})

it('ignores DOM nodes matching css selector', () => {
const Component = defineComponent({
components: { Hello },
template:
'<div class="foo"><Hello class="foo" /><div class="nested foo"></div></div>'
})
const wrapper = mount(Component)
expect(wrapper.findAllComponents('.foo')).toHaveLength(1)
})

it('findAllComponents returns top-level components when components are nested', () => {
const DeepNestedChild = {
name: 'DeepNestedChild',
template: '<div>I am deeply nested</div>'
}
const NestedChild = {
name: 'NestedChild',
components: { DeepNestedChild },
template: '<deep-nested-child class="in-child" />'
}
const RootComponent = {
name: 'RootComponent',
components: { NestedChild },
template: '<div><nested-child class="in-root"></nested-child></div>'
}

const wrapper = mount(RootComponent)

expect(wrapper.findAllComponents('.in-root')).toHaveLength(1)
expect(wrapper.findAllComponents('.in-root')[0].vm.$options.name).toEqual(
'NestedChild'
)

expect(wrapper.findAllComponents('.in-child')).toHaveLength(1)

// someone might expect DeepNestedChild here, but
// we always return TOP component matching DOM element
expect(wrapper.findAllComponents('.in-child')[0].vm.$options.name).toEqual(
'NestedChild'
)
})
})
20 changes: 18 additions & 2 deletions tests/findComponent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ const compA = defineComponent({
describe('findComponent', () => {
it('does not find plain dom elements', () => {
const wrapper = mount(compA)
// @ts-expect-error
expect(() => wrapper.findComponent('.domElement')).toThrowError()
expect(wrapper.findComponent('.domElement').exists()).toBeFalsy()
})

it('finds component by ref', () => {
Expand All @@ -60,6 +59,23 @@ describe('findComponent', () => {
expect(wrapper.findComponent({ ref: 'hello' }).exists()).toBe(false)
})

it('finds component by dom selector', () => {
const wrapper = mount(compA)
// find by DOM selector
expect(wrapper.findComponent('.C').vm).toHaveProperty(
'$options.name',
'ComponentC'
)
})

it('does allows using complicated DOM selector query', () => {
const wrapper = mount(compA)
expect(wrapper.findComponent('.B > .C').vm).toHaveProperty(
'$options.name',
'ComponentC'
)
})

it('finds component by name', () => {
const wrapper = mount(compA)
expect(wrapper.findComponent({ name: 'Hello' }).text()).toBe('Hello world')
Expand Down
13 changes: 6 additions & 7 deletions tests/getComponent.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineComponent } from 'vue'
import { mount, RouterLinkStub, shallowMount } from '../src'
import { mount, MountingOptions, RouterLinkStub, shallowMount } from '../src'
import Issue425 from './components/Issue425.vue'

const compA = defineComponent({
Expand All @@ -15,15 +15,14 @@ describe('getComponent', () => {
it('should delegate to findComponent', () => {
const wrapper = mount(compA)
jest.spyOn(wrapper, 'findComponent').mockReturnThis()
wrapper.getComponent(compA)
expect(wrapper.findComponent).toHaveBeenCalledWith(compA)
wrapper.getComponent('.domElement')
expect(wrapper.findComponent).toHaveBeenCalledWith('.domElement')
})

it('should throw if not found with a string selector', () => {
const wrapper = mount(compA)
// @ts-expect-error
expect(() => wrapper.getComponent('.domElement')).toThrowError(
'findComponent requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
'Unable to get component with selector .domElement within: <div class="A"></div>'
)
})

Expand Down Expand Up @@ -70,12 +69,12 @@ describe('getComponent', () => {
// https://github.com/vuejs/vue-test-utils-next/issues/425
it('works with router-link and mount', () => {
const wrapper = mount(Issue425, options)
expect(wrapper.getComponent(RouterLinkStub).props('to')).toEqual({ name })
expect(wrapper.getComponent('.link').props('to')).toEqual({ name })
})

// https://github.com/vuejs/vue-test-utils-next/issues/425
it('works with router-link and shallowMount', () => {
const wrapper = shallowMount(Issue425, options)
expect(wrapper.getComponent(RouterLinkStub).props('to')).toEqual({ name })
expect(wrapper.getComponent('.link').props('to')).toEqual({ name })
})
})

0 comments on commit 239caf8

Please sign in to comment.