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

Find by component #66

Merged
merged 11 commits into from
Apr 18, 2020
79 changes: 79 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,85 @@ test('findAll', () => {
})
```

### `findComponent`

Finds a Vue Component instance and returns a `VueWrapper` if one is found, otherwise returns `ErrorWrapper`.

**Supported syntax:**

* **querySelector** - `findComponent('.component')` - Matches standard query selector.
* **Name** - `findComponent({ name: 'myComponent' })` - matches PascalCase, snake-case, camelCase
* **ref** - `findComponent({ ref: 'dropdown' })` - Can be used only on direct ref children of mounted component
* **SFC** - `findComponent(ImportedComponent)` - Pass an imported component directly.

```vue
<template>
<div class="foo">
Foo
</div>
</template>
<script>
export default { name: 'Foo' }
</script>
```

```vue
<template>
<div>
<span>Span</span>
<Foo data-test="foo" ref="foo"/>
</div>
</template>
```

```js
test('find', () => {
const wrapper = mount(Component)

wrapper.find('.foo') //=> found; returns VueWrapper
wrapper.find('[data-test="foo"]') //=> found; returns VueWrapper
wrapper.find({ name: 'Foo' }) //=> found; returns VueWrapper
wrapper.find({ name: 'foo' }) //=> found; returns VueWrapper
wrapper.find({ ref: 'foo' }) //=> found; returns VueWrapper
wrapper.find(Foo) //=> found; returns VueWrapper
})
```

### `findAllComponents`

Similar to `findComponent` but finds all Vue Component instances that match the query and returns an array of `VueWrapper`.

**Supported syntax:**

* **querySelector** - `findAllComponents('.component')`
* **Name** - `findAllComponents({ name: 'myComponent' })`
* **SFC** - `findAllComponents(ImportedComponent)`

**Note** - `Ref` is not supported here.


```vue
<template>
<div>
<FooComponent
v-for="number in [1, 2, 3]"
:key="number"
data-test="number"
>
{{ number }}
</FooComponent>
</div>
</template>
```

```js
test('findAllComponents', () => {
const wrapper = mount(Component)

wrapper.findAllComponents('[data-test="number"]') //=> found; returns array of VueWrapper
})
```

### `trigger`

Simulates an event, for example `click`, `submit` or `keyup`. Since events often cause a re-render, `trigger` returs `Vue.nextTick`. If you expect the event to trigger a re-render, you should use `await` when you call `trigger` to ensure that Vue updates the DOM before you make an assertion.
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const MOUNT_ELEMENT_ID = 'app'
export const MOUNT_COMPONENT_REF = 'VTU_COMPONENT'
export const MOUNT_PARENT_NAME = 'VTU_ROOT'
14 changes: 5 additions & 9 deletions src/emitMixin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { getCurrentInstance } from 'vue'

export const createEmitMixin = () => {
const events: Record<string, unknown[]> = {}

const emitMixin = {
export const attachEmitListener = () => {
return {
beforeCreate() {
let events: Record<string, unknown[]> = {}
this.__emitted = events
dobromir-hristov marked this conversation as resolved.
Show resolved Hide resolved

getCurrentInstance().emit = (event: string, ...args: unknown[]) => {
events[event]
? (events[event] = [...events[event], [...args]])
Expand All @@ -14,9 +15,4 @@ export const createEmitMixin = () => {
}
}
}

return {
events,
emitMixin
}
}
14 changes: 10 additions & 4 deletions src/error-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { FindComponentSelector } from './types'

interface Options {
selector: string
selector: FindComponentSelector
}

export class ErrorWrapper {
selector: string
selector: FindComponentSelector
element: null

constructor({ selector }: Options) {
Expand All @@ -14,6 +16,10 @@ export class ErrorWrapper {
return Error(`Cannot call ${method} on an empty wrapper.`)
}

vm(): Error {
throw this.wrapperError('vm')
}

attributes() {
throw this.wrapperError('attributes')
}
Expand All @@ -34,8 +40,8 @@ export class ErrorWrapper {
throw this.wrapperError('findAll')
}

setChecked() {
throw this.wrapperError('setChecked')
setProps() {
throw this.wrapperError('setProps')
}

setValue() {
Expand Down
19 changes: 11 additions & 8 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ import {
} from 'vue'

import { createWrapper, VueWrapper } from './vue-wrapper'
import { createEmitMixin } from './emitMixin'
import { attachEmitListener } from './emitMixin'
import { createDataMixin } from './dataMixin'
import { MOUNT_ELEMENT_ID } from './constants'
import {
MOUNT_COMPONENT_REF,
MOUNT_ELEMENT_ID,
MOUNT_PARENT_NAME
} from './constants'
import { stubComponents } from './stubs'

type Slot = VNode | string | { render: Function }
Expand Down Expand Up @@ -86,11 +90,11 @@ export function mount(

// we define props as reactive so that way when we update them with `setProps`
// Vue's reactivity system will cause a rerender.
const props = reactive({ ...options?.props, ref: 'VTU_COMPONENT' })
const props = reactive({ ...options?.props, ref: MOUNT_COMPONENT_REF })

// create the wrapper component
const Parent = defineComponent({
name: 'VTU_COMPONENT',
name: MOUNT_PARENT_NAME,
render() {
return h(component, props, slots)
}
Expand Down Expand Up @@ -149,8 +153,7 @@ export function mount(
}

// add tracking for emitted events
const { emitMixin, events } = createEmitMixin()
vm.mixin(emitMixin)
vm.mixin(attachEmitListener())

// stubs
if (options?.global?.stubs) {
Expand All @@ -161,6 +164,6 @@ export function mount(

// mount the app!
const app = vm.mount(el)

return createWrapper(app, events, setProps)
const App = app.$refs[MOUNT_COMPONENT_REF] as ComponentPublicInstance
return createWrapper(App, setProps)
}
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ export interface WrapperAPI {
text: () => string
trigger: (eventString: string) => Promise<(fn?: () => void) => Promise<void>>
}

interface RefSelector {
ref: string
}

interface NameSelector {
name: string
}

export type FindComponentSelector = RefSelector | NameSelector | string
export type FindAllComponentsSelector = NameSelector | string
61 changes: 61 additions & 0 deletions src/utils/find.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { VNode, ComponentPublicInstance } from 'vue'
import { FindAllComponentsSelector } from '../types'
import { matchName } from './matchName'

/**
* Detect whether a selector matches a VNode
* @param node
* @param selector
* @return {boolean | ((value: any) => boolean)}
*/
function matches(node: VNode, selector: FindAllComponentsSelector): boolean {
// do not return none Vue components
if (!node.component) return false

if (typeof selector === 'string') {
return node.el?.matches?.(selector)
}

if (typeof selector === 'object' && typeof node.type === 'object') {
if (selector.name && ('name' in node.type || 'displayName' in node.type)) {
dobromir-hristov marked this conversation as resolved.
Show resolved Hide resolved
// match normal component definitions or functional components
return matchName(selector.name, node.type.name || node.type.displayName)
}
}

return false
}

/**
* Collect all children
* @param nodes
* @param children
*/
function aggregateChildren(nodes, children) {
if (children && Array.isArray(children)) {
;[...children].reverse().forEach((n: VNode) => {
nodes.unshift(n)
})
}
}

function findAllVNodes(vnode: VNode, selector: any): VNode[] {
const matchingNodes = []
const nodes = [vnode]
while (nodes.length) {
const node = nodes.shift()
aggregateChildren(nodes, node.children)
aggregateChildren(nodes, node.component?.subTree.children)
if (matches(node, selector)) {
matchingNodes.push(node)
}
}

return matchingNodes
}

export function find(root: VNode, selector: any): ComponentPublicInstance[] {
return findAllVNodes(root, selector).map(
(vnode: VNode) => vnode.component.proxy
)
}
14 changes: 14 additions & 0 deletions src/utils/matchName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { camelize, capitalize } from '@vue/shared'

dobromir-hristov marked this conversation as resolved.
Show resolved Hide resolved
export function matchName(target, sourceName) {
const camelized = camelize(target)
const capitalized = capitalize(camelized)

return (
sourceName &&
(sourceName === target ||
sourceName === camelized ||
sourceName === capitalized ||
capitalize(camelize(sourceName)) === capitalized)
)
}
Loading