Skip to content

Commit d0fd353

Browse files
authored
Merge pull request #66 from vuejs/feat/find-by-component
feat: Find by component
2 parents 72bd1cd + fd242b8 commit d0fd353

15 files changed

+397
-57
lines changed

Diff for: docs/API.md

+79
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,85 @@ test('findAll', () => {
348348
})
349349
```
350350

351+
### `findComponent`
352+
353+
Finds a Vue Component instance and returns a `VueWrapper` if one is found, otherwise returns `ErrorWrapper`.
354+
355+
**Supported syntax:**
356+
357+
* **querySelector** - `findComponent('.component')` - Matches standard query selector.
358+
* **Name** - `findComponent({ name: 'myComponent' })` - matches PascalCase, snake-case, camelCase
359+
* **ref** - `findComponent({ ref: 'dropdown' })` - Can be used only on direct ref children of mounted component
360+
* **SFC** - `findComponent(ImportedComponent)` - Pass an imported component directly.
361+
362+
```vue
363+
<template>
364+
<div class="foo">
365+
Foo
366+
</div>
367+
</template>
368+
<script>
369+
export default { name: 'Foo' }
370+
</script>
371+
```
372+
373+
```vue
374+
<template>
375+
<div>
376+
<span>Span</span>
377+
<Foo data-test="foo" ref="foo"/>
378+
</div>
379+
</template>
380+
```
381+
382+
```js
383+
test('find', () => {
384+
const wrapper = mount(Component)
385+
386+
wrapper.find('.foo') //=> found; returns VueWrapper
387+
wrapper.find('[data-test="foo"]') //=> found; returns VueWrapper
388+
wrapper.find({ name: 'Foo' }) //=> found; returns VueWrapper
389+
wrapper.find({ name: 'foo' }) //=> found; returns VueWrapper
390+
wrapper.find({ ref: 'foo' }) //=> found; returns VueWrapper
391+
wrapper.find(Foo) //=> found; returns VueWrapper
392+
})
393+
```
394+
395+
### `findAllComponents`
396+
397+
Similar to `findComponent` but finds all Vue Component instances that match the query and returns an array of `VueWrapper`.
398+
399+
**Supported syntax:**
400+
401+
* **querySelector** - `findAllComponents('.component')`
402+
* **Name** - `findAllComponents({ name: 'myComponent' })`
403+
* **SFC** - `findAllComponents(ImportedComponent)`
404+
405+
**Note** - `Ref` is not supported here.
406+
407+
408+
```vue
409+
<template>
410+
<div>
411+
<FooComponent
412+
v-for="number in [1, 2, 3]"
413+
:key="number"
414+
data-test="number"
415+
>
416+
{{ number }}
417+
</FooComponent>
418+
</div>
419+
</template>
420+
```
421+
422+
```js
423+
test('findAllComponents', () => {
424+
const wrapper = mount(Component)
425+
426+
wrapper.findAllComponents('[data-test="number"]') //=> found; returns array of VueWrapper
427+
})
428+
```
429+
351430
### `trigger`
352431

353432
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.

Diff for: src/constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export const MOUNT_ELEMENT_ID = 'app'
2+
export const MOUNT_COMPONENT_REF = 'VTU_COMPONENT'
3+
export const MOUNT_PARENT_NAME = 'VTU_ROOT'

Diff for: src/emitMixin.ts

+5-9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { getCurrentInstance } from 'vue'
22

3-
export const createEmitMixin = () => {
4-
const events: Record<string, unknown[]> = {}
5-
6-
const emitMixin = {
3+
export const attachEmitListener = () => {
4+
return {
75
beforeCreate() {
6+
let events: Record<string, unknown[]> = {}
7+
this.__emitted = events
8+
89
getCurrentInstance().emit = (event: string, ...args: unknown[]) => {
910
events[event]
1011
? (events[event] = [...events[event], [...args]])
@@ -14,9 +15,4 @@ export const createEmitMixin = () => {
1415
}
1516
}
1617
}
17-
18-
return {
19-
events,
20-
emitMixin
21-
}
2218
}

Diff for: src/error-wrapper.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { FindComponentSelector } from './types'
2+
13
interface Options {
2-
selector: string
4+
selector: FindComponentSelector
35
}
46

57
export class ErrorWrapper {
6-
selector: string
8+
selector: FindComponentSelector
79
element: null
810

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

19+
vm(): Error {
20+
throw this.wrapperError('vm')
21+
}
22+
1723
attributes() {
1824
throw this.wrapperError('attributes')
1925
}
@@ -34,8 +40,8 @@ export class ErrorWrapper {
3440
throw this.wrapperError('findAll')
3541
}
3642

37-
setChecked() {
38-
throw this.wrapperError('setChecked')
43+
setProps() {
44+
throw this.wrapperError('setProps')
3945
}
4046

4147
setValue() {

Diff for: src/mount.ts

+11-8
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ import {
1414
} from 'vue'
1515

1616
import { createWrapper, VueWrapper } from './vue-wrapper'
17-
import { createEmitMixin } from './emitMixin'
17+
import { attachEmitListener } from './emitMixin'
1818
import { createDataMixin } from './dataMixin'
19-
import { MOUNT_ELEMENT_ID } from './constants'
19+
import {
20+
MOUNT_COMPONENT_REF,
21+
MOUNT_ELEMENT_ID,
22+
MOUNT_PARENT_NAME
23+
} from './constants'
2024
import { stubComponents } from './stubs'
2125

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

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

9195
// create the wrapper component
9296
const Parent = defineComponent({
93-
name: 'VTU_COMPONENT',
97+
name: MOUNT_PARENT_NAME,
9498
render() {
9599
return h(component, props, slots)
96100
}
@@ -149,8 +153,7 @@ export function mount(
149153
}
150154

151155
// add tracking for emitted events
152-
const { emitMixin, events } = createEmitMixin()
153-
vm.mixin(emitMixin)
156+
vm.mixin(attachEmitListener())
154157

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

162165
// mount the app!
163166
const app = vm.mount(el)
164-
165-
return createWrapper(app, events, setProps)
167+
const App = app.$refs[MOUNT_COMPONENT_REF] as ComponentPublicInstance
168+
return createWrapper(App, setProps)
166169
}

Diff for: src/types.ts

+11
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,14 @@ export interface WrapperAPI {
1212
text: () => string
1313
trigger: (eventString: string) => Promise<(fn?: () => void) => Promise<void>>
1414
}
15+
16+
interface RefSelector {
17+
ref: string
18+
}
19+
20+
interface NameSelector {
21+
name: string
22+
}
23+
24+
export type FindComponentSelector = RefSelector | NameSelector | string
25+
export type FindAllComponentsSelector = NameSelector | string

Diff for: src/utils/find.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { VNode, ComponentPublicInstance } from 'vue'
2+
import { FindAllComponentsSelector } from '../types'
3+
import { matchName } from './matchName'
4+
5+
/**
6+
* Detect whether a selector matches a VNode
7+
* @param node
8+
* @param selector
9+
* @return {boolean | ((value: any) => boolean)}
10+
*/
11+
function matches(node: VNode, selector: FindAllComponentsSelector): boolean {
12+
// do not return none Vue components
13+
if (!node.component) return false
14+
15+
if (typeof selector === 'string') {
16+
return node.el?.matches?.(selector)
17+
}
18+
19+
if (typeof selector === 'object' && typeof node.type === 'object') {
20+
if (selector.name && ('name' in node.type || 'displayName' in node.type)) {
21+
// match normal component definitions or functional components
22+
return matchName(selector.name, node.type.name || node.type.displayName)
23+
}
24+
}
25+
26+
return false
27+
}
28+
29+
/**
30+
* Collect all children
31+
* @param nodes
32+
* @param children
33+
*/
34+
function aggregateChildren(nodes, children) {
35+
if (children && Array.isArray(children)) {
36+
;[...children].reverse().forEach((n: VNode) => {
37+
nodes.unshift(n)
38+
})
39+
}
40+
}
41+
42+
function findAllVNodes(vnode: VNode, selector: any): VNode[] {
43+
const matchingNodes = []
44+
const nodes = [vnode]
45+
while (nodes.length) {
46+
const node = nodes.shift()
47+
aggregateChildren(nodes, node.children)
48+
aggregateChildren(nodes, node.component?.subTree.children)
49+
if (matches(node, selector)) {
50+
matchingNodes.push(node)
51+
}
52+
}
53+
54+
return matchingNodes
55+
}
56+
57+
export function find(root: VNode, selector: any): ComponentPublicInstance[] {
58+
return findAllVNodes(root, selector).map(
59+
(vnode: VNode) => vnode.component.proxy
60+
)
61+
}

Diff for: src/utils/matchName.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { camelize, capitalize } from '@vue/shared'
2+
3+
export function matchName(target, sourceName) {
4+
const camelized = camelize(target)
5+
const capitalized = capitalize(camelized)
6+
7+
return (
8+
sourceName &&
9+
(sourceName === target ||
10+
sourceName === camelized ||
11+
sourceName === capitalized ||
12+
capitalize(camelize(sourceName)) === capitalized)
13+
)
14+
}

0 commit comments

Comments
 (0)