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

fix: return abitility to use findComponent with DOM selector #994

Merged
merged 2 commits into from
Oct 7, 2021
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
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 })
})
})