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(find): allow chaining find with findComponent #897

Merged
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
66 changes: 62 additions & 4 deletions src/baseWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { textContent } from './utils'
import type { TriggerOptions } from './createDomEvent'
import { nextTick } from 'vue'
import {
ComponentInternalInstance,
ComponentPublicInstance,
nextTick
} from 'vue'
import { createDOMEvent } from './createDomEvent'
import { DomEventName, DomEventNameWithModifier } from './constants/dom-events'
import { DomEventNameWithModifier } from './constants/dom-events'
import type { VueWrapper } from './vueWrapper'
import type { DOMWrapper } from './domWrapper'
import { FindAllComponentsSelector, FindComponentSelector } from './types'

export default class BaseWrapper<ElementType extends Element> {
private readonly wrapperElement: ElementType
export default abstract class BaseWrapper<ElementType extends Element> {
private readonly wrapperElement: ElementType & {
__vueParentComponent?: ComponentInternalInstance
}

get element() {
return this.wrapperElement
Expand All @@ -15,6 +24,16 @@ export default class BaseWrapper<ElementType extends Element> {
this.wrapperElement = element
}

abstract find(selector: string): DOMWrapper<Element>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I really like how we set our expectations on both kind of wrappers (DOMWrapper & VueWrapper) by leveraging abstract methods

abstract findAll(selector: string): DOMWrapper<Element>[]
abstract findComponent<T extends ComponentPublicInstance>(
selector: FindComponentSelector | (new () => T)
): VueWrapper<T>
abstract findAllComponents(
selector: FindAllComponentsSelector
): VueWrapper<any>[]
abstract html(): string

classes(): string[]
classes(className: string): boolean
classes(className?: string): string[] | boolean {
Expand Down Expand Up @@ -45,6 +64,45 @@ export default class BaseWrapper<ElementType extends Element> {
return true
}

get<K extends keyof HTMLElementTagNameMap>(
selector: K
): Omit<DOMWrapper<HTMLElementTagNameMap[K]>, 'exists'>
get<K extends keyof SVGElementTagNameMap>(
selector: K
): Omit<DOMWrapper<SVGElementTagNameMap[K]>, 'exists'>
get<T extends Element>(selector: string): Omit<DOMWrapper<T>, 'exists'>
get(selector: string): Omit<DOMWrapper<Element>, 'exists'> {
const result = this.find(selector)
if (result.exists()) {
return result
}

throw new Error(`Unable to get ${selector} within: ${this.html()}`)
}

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

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

let message = 'Unable to get '
if (typeof selector === 'string') {
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)
}

protected isDisabled = () => {
const validTagsToBeDisabled = [
'BUTTON',
Expand Down
66 changes: 50 additions & 16 deletions src/domWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { isElementVisible } from './utils/isElementVisible'
import BaseWrapper from './baseWrapper'
import { createWrapperError } from './errorWrapper'
import WrapperLike from './interfaces/wrapperLike'
import { ComponentInternalInstance, ComponentPublicInstance } from 'vue'
import { FindAllComponentsSelector, FindComponentSelector } from './types'
import { VueWrapper } from 'src'
import { matches, find } from './utils/find'
import { createWrapper } from './vueWrapper'

export class DOMWrapper<ElementType extends Element>
extends BaseWrapper<ElementType>
Expand Down Expand Up @@ -38,22 +43,6 @@ export class DOMWrapper<ElementType extends Element>
return createWrapperError('DOMWrapper')
}

get<K extends keyof HTMLElementTagNameMap>(
selector: K
): Omit<DOMWrapper<HTMLElementTagNameMap[K]>, 'exists'>
get<K extends keyof SVGElementTagNameMap>(
selector: K
): Omit<DOMWrapper<SVGElementTagNameMap[K]>, 'exists'>
get<T extends Element>(selector: string): Omit<DOMWrapper<T>, 'exists'>
get(selector: string): Omit<DOMWrapper<Element>, 'exists'> {
const result = this.find(selector)
if (result instanceof DOMWrapper) {
return result
}

throw new Error(`Unable to get ${selector} within: ${this.html()}`)
}

findAll<K extends keyof HTMLElementTagNameMap>(
selector: K
): DOMWrapper<HTMLElementTagNameMap[K]>[]
Expand All @@ -67,6 +56,51 @@ export class DOMWrapper<ElementType extends Element>
)
}

findComponent<T extends ComponentPublicInstance>(
selector: FindComponentSelector | (new () => T)
): VueWrapper<T> {
const parentComponent = this.element.__vueParentComponent

if (!parentComponent) {
return createWrapperError('VueWrapper')
}

if (typeof selector === 'object' && 'ref' in selector) {
const result = parentComponent.refs[selector.ref]
if (result && !(result instanceof HTMLElement)) {
return createWrapper(null, result as T)
} else {
return createWrapperError('VueWrapper')
}
}

if (
matches(parentComponent.vnode, selector) &&
this.element.contains(parentComponent.vnode.el as Node)
) {
return createWrapper(null, parentComponent.proxy!)
}

const result = find(parentComponent.subTree, selector).filter((v) =>
this.element.contains(v.$el)
)

if (result.length) {
return createWrapper(null, result[0])
}

return createWrapperError('VueWrapper')
}

findAllComponents(selector: FindAllComponentsSelector): VueWrapper<any>[] {
const parentComponent: ComponentInternalInstance = (this.element as any)
.__vueParentComponent

return find(parentComponent.subTree, selector)
.filter((v) => this.element.contains(v.$el))
.map((c) => createWrapper(null, c))
}

private async setChecked(checked: boolean = true) {
// typecast so we get type safety
const element = this.element as unknown as HTMLInputElement
Expand Down
41 changes: 1 addition & 40 deletions src/vueWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,22 +132,6 @@ export class VueWrapper<T extends ComponentPublicInstance>
return createWrapperError('DOMWrapper')
}

get<K extends keyof HTMLElementTagNameMap>(
selector: K
): Omit<DOMWrapper<HTMLElementTagNameMap[K]>, 'exists'>
get<K extends keyof SVGElementTagNameMap>(
selector: K
): Omit<DOMWrapper<SVGElementTagNameMap[K]>, 'exists'>
get<T extends Element>(selector: string): Omit<DOMWrapper<T>, 'exists'>
get(selector: string): Omit<DOMWrapper<Element>, 'exists'> {
const result = this.find(selector)
if (result instanceof DOMWrapper) {
return result
}

throw new Error(`Unable to get ${selector} within: ${this.html()}`)
}

findComponent<T extends ComponentPublicInstance>(
selector: FindComponentSelector | (new () => T)
): VueWrapper<T> {
Expand Down Expand Up @@ -182,30 +166,7 @@ export class VueWrapper<T extends ComponentPublicInstance>
return createWrapperError('VueWrapper')
}

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') {
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>[] {
findAllComponents(selector: FindAllComponentsSelector): VueWrapper<any>[] {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Replaced VueWrapper<T> with VueWrapper<any>. While this typing could be definitely improved (I hate any anywhere 🤣 ) it is at least "not incorrect" - <T> here was a type of current instance, so if you've done

const wrapper = mount(Hello);
const cmps = wrapper.findAll(Foo);

cmps was wrongly typed to VueWrapper<Hello>[] instead of VueWrapper<Foo>[]

Copy link
Member

Choose a reason for hiding this comment

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

Oh good catch! But any feels definitely wrong.
Would you mind opening a dedicated PR to fix this properly (or we can file an issue)?
I think it would be better to at least let developers provide the type like wrapper.findAllComponents<Foo>(Foo) to get Array<VueWrapper<Foo>>. Or even better infer it from the selector if possible (but I'm not sure it's doable in all cases, as the developer may specify { name: 'Foo' } as a selector).
What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Definitely agree. I've opened separate issue #899 because I can't estimate effort to fix this properly, yet current PR has value - it is LESS wrong than previous version.

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'
Expand Down
11 changes: 11 additions & 0 deletions tests/findAllComponents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,15 @@ describe('findAllComponents', () => {
)
expect(wrapper.findAllComponents(Hello)[0].text()).toBe('Hello world')
})

it('finds all deeply nested vue components when chained from dom wrapper', () => {
const Component = defineComponent({
components: { Hello },
template:
'<div><Hello /><div class="nested"><Hello /><Hello /></div></div>'
})
const wrapper = mount(Component)
expect(wrapper.findAllComponents(Hello)).toHaveLength(3)
expect(wrapper.find('.nested').findAllComponents(Hello)).toHaveLength(2)
})
})
69 changes: 69 additions & 0 deletions tests/findComponent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,73 @@ describe('findComponent', () => {
})
expect(wrapper.findComponent(Func).exists()).toBe(true)
})

describe('chaining from dom wrapper', () => {
it('finds a component nested inside a node', () => {
const Comp = defineComponent({
components: { Hello: Hello },
template: '<div><div class="nested"><Hello /></div></div>'
})

const wrapper = mount(Comp)
expect(wrapper.find('.nested').findComponent(Hello).exists()).toBe(true)
})

it('finds a component inside DOM node', () => {
const Comp = defineComponent({
components: { Hello: Hello },
template:
'<div><Hello class="one"/><div class="nested"><Hello class="two" /></div>'
})

const wrapper = mount(Comp)
expect(wrapper.find('.nested').findComponent(Hello).classes('two')).toBe(
true
)
})

it('returns correct instance of recursive component', () => {
const Comp = defineComponent({
name: 'Comp',
props: ['firstLevel'],
template:
'<div class="first"><div class="nested"><Comp v-if="firstLevel" class="second" /></div>'
})

const wrapper = mount(Comp, { props: { firstLevel: true } })
expect(
wrapper.find('.nested').findComponent(Comp).classes('second')
).toBe(true)
})

it('returns top-level component if it matches', () => {
const Comp = defineComponent({
name: 'Comp',
template: '<div class="top"></div>'
})

const wrapper = mount(Comp)
expect(wrapper.find('.top').findComponent(Comp).classes('top')).toBe(true)
})

it('uses refs of correct component when searching by ref', () => {
const Child = defineComponent({
components: { Hello },
template: '<div><Hello ref="testRef" class="inside"></div>'
})
const Comp = defineComponent({
components: { Child, Hello },
template:
'<div><Child class="nested" /><Hello ref="testRef" class="outside" /></div>'
})

const wrapper = mount(Comp)
expect(
wrapper
.find('.nested')
.findComponent({ ref: 'testRef' })
.classes('inside')
).toBe(true)
})
})
})