From 239caf8bdbc7d9847f367ce0a3ca9df0402d215c Mon Sep 17 00:00:00 2001 From: Illya Klymov Date: Thu, 7 Oct 2021 03:58:17 +0300 Subject: [PATCH] fix: return abitility to use findComponent with DOM selector (#994) * fix: return ability to use findComponent with DOM selector * fix: ensure findAllComponents return at most one vnode for same el --- docs/api/index.md | 31 +++++++++++++++++++++++ src/types.ts | 4 +-- src/utils/find.ts | 14 +++++++--- src/vueWrapper.ts | 32 +++++++++++++++-------- test-dts/getComponent.d-test.ts | 5 ++++ tests/attributes.spec.ts | 2 +- tests/findAllComponents.spec.ts | 45 ++++++++++++++++++++++++++++++++- tests/findComponent.spec.ts | 20 +++++++++++++-- tests/getComponent.spec.ts | 13 +++++----- 9 files changed, 139 insertions(+), 27 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 167bcf47a..8441d1f2a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1103,6 +1103,7 @@ findComponent(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 | @@ -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 @@ -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: '
' +} +const RootComponent = { + name: 'Root', + components: { ChildComponent }, + template: '' +} +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:** @@ -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. diff --git a/src/types.ts b/src/types.ts index 375793348..9a6f473cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 diff --git a/src/utils/find.ts b/src/utils/find.ts index 2ed94c630..3531866af 100644 --- a/src/utils/find.ts +++ b/src/utils/find.ts @@ -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!) } diff --git a/src/vueWrapper.ts b/src/vueWrapper.ts index 78cf72757..59be68aae 100644 --- a/src/vueWrapper.ts +++ b/src/vueWrapper.ts @@ -140,12 +140,6 @@ export class VueWrapper findComponent( selector: FindComponentSelector | (new () => T) ): VueWrapper { - 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)) { @@ -171,13 +165,31 @@ export class VueWrapper return createWrapperError('VueWrapper') } - findAllComponents(selector: FindAllComponentsSelector): VueWrapper[] { + getComponent( + selector: FindComponentSelector | (new () => T) + ): Omit, 'exists'> { + const result = this.findComponent(selector) + + if (result instanceof VueWrapper) { + return result as VueWrapper + } + + 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[] { + const results = find(this.vm.$.subTree, selector) return find(this.vm.$.subTree, selector).map((c) => createWrapper(null, c)) } diff --git a/test-dts/getComponent.d-test.ts b/test-dts/getComponent.d-test.ts index c5bbb5429..5914c9566 100644 --- a/test-dts/getComponent.d-test.ts +++ b/test-dts/getComponent.d-test.ts @@ -28,6 +28,11 @@ const componentByName = wrapper.getComponent({ name: 'ComponentToFind' }) // returns a wrapper with a generic vm (any) expectType(componentByName.vm) +// get by string +const componentByString = wrapper.getComponent('other') +// returns a wrapper with a generic vm (any) +expectType(componentByString.vm) + // get by ref const componentByRef = wrapper.getComponent({ ref: 'ref' }) // returns a wrapper with a generic vm (any) diff --git a/tests/attributes.spec.ts b/tests/attributes.spec.ts index 98fed7d88..412edbfa5 100644 --- a/tests/attributes.spec.ts +++ b/tests/attributes.spec.ts @@ -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: '' diff --git a/tests/findAllComponents.spec.ts b/tests/findAllComponents.spec.ts index 068b42d35..b5d997ef5 100644 --- a/tests/findAllComponents.spec.ts +++ b/tests/findAllComponents.spec.ts @@ -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' ) @@ -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: + '
' + }) + 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: '
I am deeply nested
' + } + const NestedChild = { + name: 'NestedChild', + components: { DeepNestedChild }, + template: '' + } + const RootComponent = { + name: 'RootComponent', + components: { NestedChild }, + template: '
' + } + + 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' + ) + }) }) diff --git a/tests/findComponent.spec.ts b/tests/findComponent.spec.ts index 32b310205..86812b559 100644 --- a/tests/findComponent.spec.ts +++ b/tests/findComponent.spec.ts @@ -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', () => { @@ -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') diff --git a/tests/getComponent.spec.ts b/tests/getComponent.spec.ts index c0b62ad19..a642b7516 100644 --- a/tests/getComponent.spec.ts +++ b/tests/getComponent.spec.ts @@ -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({ @@ -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:
' ) }) @@ -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 }) }) })