Skip to content

Commit

Permalink
feat(VTreeview): add search functionality (#6124)
Browse files Browse the repository at this point in the history
* feat(VTreeview): added search and customFilter props

* feat(VTreeview): filtering items using display: none

previuos method was destructive in that it modified original items array

* chore(VTreeview): add example for search

add newIn flags

* chore: fixed customFilter definition

* docs(VTreeview): add missing prop language

* refactor: use PropValidator type on customFilter

resolves #5621
  • Loading branch information
nekosaur authored and johnleider committed Feb 4, 2019
1 parent f3e09f5 commit 75bbcc5
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 3 deletions.
3 changes: 3 additions & 0 deletions packages/docs/src/data/new.json
Expand Up @@ -46,6 +46,9 @@
"disabled": "1.4",
"readonly": "1.2"
},
"v-treeview": {
"search": "1.5"
},
"v-select": {
"small-chips": "1.1",
"menu-props": "1.2"
Expand Down
1 change: 1 addition & 0 deletions packages/docs/src/data/pages/components/Treeview.json
Expand Up @@ -26,6 +26,7 @@
{
"type": "examples",
"value": [
{ "file": "humanResources", "newIn": "1.5" },
"fileExplorer",
"directory",
"hotspots"
Expand Down
111 changes: 111 additions & 0 deletions packages/docs/src/examples/treeview/humanResources.vue
@@ -0,0 +1,111 @@
<template>
<v-card
class="mx-auto"
max-width="500"
>
<v-card-title class="primary lighten-2">
<v-text-field
v-model="search"
label="Search Company Directory"
dark
flat
solo-inverted
hide-details
clearable
clear-icon="mdi-close-circle-outline"
></v-text-field>
</v-card-title>
<v-card-text>
<v-treeview
:items="items"
:search="search"
:open.sync="open"
>
<template
slot="prepend"
slot-scope="{ item }"
>
<v-icon
v-if="item.children"
v-text="`mdi-${item.id === 1 ? 'home-variant' : 'folder-network'}`"
></v-icon>
</template>
</v-treeview>
</v-card-text>
</v-card>
</template>

<script>
export default {
data: () => ({
items: [
{
id: 1,
name: 'Vuetify Human Resources',
children: [
{
id: 2,
name: 'Core team',
children: [
{
id: 201,
name: 'John'
},
{
id: 202,
name: 'Kael'
},
{
id: 203,
name: 'Nekosaur'
},
{
id: 204,
name: 'Jacek'
},
{
id: 205,
name: 'Andrew'
}
]
},
{
id: 3,
name: 'Administrators',
children: [
{
id: 301,
name: 'Ranee'
},
{
id: 302,
name: 'Rachel'
}
]
},
{
id: 4,
name: 'Contributors',
children: [
{
id: 401,
name: 'Phlow'
},
{
id: 402,
name: 'Brandon'
},
{
id: 403,
name: 'Sean'
}
]
}
]
}
],
open: [1, 2],
search: null
})
}
</script>
6 changes: 6 additions & 0 deletions packages/docs/src/lang/en-US/components/Treeview.json
Expand Up @@ -16,13 +16,18 @@
"hotspots": {
"header": "### Custom selectable icons",
"desc": "Customize the **on**, **off** and **indeterminate** icons for your selectable tree. Combine with other advanced functionality like API loaded items."
},
"humanResources": {
"header": "### Searching a directory",
"desc": "Easily filter your treeview by using the **search** prop. This works similar to the [v-autocomplete](/components/autocompletes) component."
}
},
"props": {
"v-treeview": {
"activatable": "Allows user to mark a node as active by clicking on it",
"active": "Syncable prop that allows one to control which nodes are active. The array consists of the `item-key` of each active item.",
"activeClass": "The class applied to the node when active",
"customFilter": "Custom search filter",
"expandIcon": "Icon used to indicate that a node can be expanded",
"hoverable": "Applies a hover class when mousing over nodes",
"indeterminateIcon": "Icon used when node is in an indeterminate state",
Expand All @@ -39,6 +44,7 @@
"openAll": "When `true` will cause all branch nodes to be opened when component is mounted",
"openOnClick": "When `true` will cause nodes to be opened by clicking anywhere on it, instead of only opening by clicking on expand icon. When using this prop with `activatable` you will be unable to mark nodes with children as active.",
"returnObject": "When `true` will make v-model, `active.sync` and `open.sync` return the complete object instead of just the key",
"search": "The search model for filtering results",
"selectable": "Will render a checkbox next to each node allowing them to be selected",
"selectedColor": "The color of the selection checkbox",
"transition": "Applies a transition when nodes are opened and closed",
Expand Down
26 changes: 25 additions & 1 deletion packages/vuetify/src/components/VTreeview/VTreeview.ts
Expand Up @@ -13,7 +13,7 @@ import Themeable from '../../mixins/themeable'
import { provide as RegistrableProvide } from '../../mixins/registrable'

// Utils
import { getObjectValueByPath, deepEqual, arrayDiff } from '../../util/helpers'
import { getObjectValueByPath, deepEqual, filterTreeItems, arrayDiff } from '../../util/helpers'
import mixins from '../../util/mixins'
import { consoleWarn } from '../../util/console'

Expand Down Expand Up @@ -68,6 +68,21 @@ export default mixins(
type: Array,
default: () => ([])
} as PropValidator<NodeArray>,
search: String,
customFilter: {
type: Function as any,
default: (items, search, idKey, textKey, childrenKey) => {
const excluded = new Set<string|number>()

if (!search) return excluded

for (let i = 0; i < items.length; i++) {
filterTreeItems(items[i], search, idKey, textKey, childrenKey, excluded)
}

return excluded
}
} as PropValidator<(items: any[], search: string, idKey: string, textKey: string, childrenKey: string) => Set<string|number>>,
...VTreeviewNodeProps
},

Expand All @@ -78,6 +93,12 @@ export default mixins(
openCache: new Set() as NodeCache
}),

computed: {
excludedItems (): Set<string | number> {
return this.customFilter(this.items.slice(), this.search, this.itemKey, this.itemText, this.itemChildren)
}
},

watch: {
items: {
handler () {
Expand Down Expand Up @@ -329,6 +350,9 @@ export default mixins(
node.vnode.isActive = node.isActive
node.vnode.isOpen = node.isOpen
}
},
isExcluded (key: string | number) {
return !!this.search && this.excludedItems.has(key)
}
},

Expand Down
3 changes: 2 additions & 1 deletion packages/vuetify/src/components/VTreeview/VTreeviewNode.ts
Expand Up @@ -295,7 +295,8 @@ export default mixins<options>(
class: {
'v-treeview-node--leaf': !this.hasChildren,
'v-treeview-node--click': this.openOnClick,
'v-treeview-node--selected': this.isSelected
'v-treeview-node--selected': this.isSelected,
'v-treeview-node--excluded': this.treeview.isExcluded(this.key)
}
}, children)
}
Expand Down
3 changes: 3 additions & 0 deletions packages/vuetify/src/stylus/components/_treeview.styl
Expand Up @@ -44,6 +44,9 @@ rtl(v-treeview-rtl, "v-treeview")
&-node
margin-left: 26px

&--excluded
display: none

&--click
> .v-treeview-node__root,
> .v-treeview-node__root > .v-treeview-node__content > *
Expand Down
32 changes: 32 additions & 0 deletions packages/vuetify/src/util/helpers.ts
Expand Up @@ -324,6 +324,38 @@ export const camelize = (str: string): string => {
return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '')
}

export function filterTreeItems (
item: any,
search: string,
idKey: string,
textKey: string,
childrenKey: string,
excluded: Set<string | number>
): boolean {
const text = getObjectValueByPath(item, textKey)

if (text.toLocaleLowerCase().indexOf(search.toLocaleLowerCase()) > -1) {
return true
}

const children = getObjectValueByPath(item, childrenKey)

if (children) {
let match = false
for (let i = 0; i < children.length; i++) {
if (filterTreeItems(children[i], search, idKey, textKey, childrenKey, excluded)) {
match = true
}
}

if (match) return true
}

excluded.add(getObjectValueByPath(item, idKey))

return false
}

/**
* Returns the set difference of B and A, i.e. the set of elements in B but not in A
*/
Expand Down
28 changes: 28 additions & 0 deletions packages/vuetify/test/unit/components/VTreeview/VTreeview.spec.js
Expand Up @@ -412,6 +412,34 @@ test('VTreeView.ts', ({ mount }) => {
expect(Object.keys(wrapper.vm.nodes).length).toBe(2)
})

it('should filter items', async () => {
const wrapper = mount(VTreeview, {
propsData: {
items: [
{
id: 1,
name: 'one'
},
{
id: 2,
name: 'two'
}
]
}
})

expect(wrapper.html()).toMatchSnapshot()

wrapper.setProps({
search: 'two'
})

await wrapper.vm.$nextTick()

expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.find('.v-treeview-node--excluded').length).toBe(1)
})

it('should emit objects when return-object prop is used', async () => {
const items = [{ id: 0, name: 'Root', children: [{ id: 1, name: 'Child' }] }]

Expand Down
Expand Up @@ -37,7 +37,8 @@ test('VTreeViewNode.ts', ({ mount }) => {
beforeEach(() => {
treeview = {
register: jest.fn(),
unregister: jest.fn()
unregister: jest.fn(),
isExcluded: () => false
}
})

Expand Down
@@ -1,5 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`VTreeView.ts should filter items 1`] = `
<div class="v-treeview theme--light">
<div class="v-treeview-node v-treeview-node--leaf">
<div class="v-treeview-node__root">
<div class="v-treeview-node__content">
<div class="v-treeview-node__label">
one
</div>
</div>
</div>
</div>
<div class="v-treeview-node v-treeview-node--leaf">
<div class="v-treeview-node__root">
<div class="v-treeview-node__content">
<div class="v-treeview-node__label">
two
</div>
</div>
</div>
</div>
</div>
`;

exports[`VTreeView.ts should filter items 2`] = `
<div class="v-treeview theme--light">
<div class="v-treeview-node v-treeview-node--leaf v-treeview-node--excluded">
<div class="v-treeview-node__root">
<div class="v-treeview-node__content">
<div class="v-treeview-node__label">
one
</div>
</div>
</div>
</div>
<div class="v-treeview-node v-treeview-node--leaf">
<div class="v-treeview-node__root">
<div class="v-treeview-node__content">
<div class="v-treeview-node__label">
two
</div>
</div>
</div>
</div>
</div>
`;

exports[`VTreeView.ts should handle replacing items with new array of equal length 1`] = `
<div class="v-treeview theme--light">
Expand Down

0 comments on commit 75bbcc5

Please sign in to comment.