diff --git a/src/components/Graph/GraphEditor.vue b/src/components/Graph/GraphEditor.vue
index 34f5dd5..ed9e287 100644
--- a/src/components/Graph/GraphEditor.vue
+++ b/src/components/Graph/GraphEditor.vue
@@ -244,7 +244,6 @@
v-model:nodesMatchingSearchQuery="nodesMatchingSearchQuery"
v-model:focusedFoundNodeId="focusedFoundNodeId"
:graph="graph"
- :renderer="renderer"
class="search-panel"
/>
@@ -517,6 +516,23 @@ export default {
this.renderer.scheduleRender()
}
}
+ },
+ nodesMatchingSearchQuery() {
+ if (this.renderer) {
+ this.renderer.refresh({
+ skipIndexation: true
+ })
+ }
+ },
+ focusedFoundNodeId() {
+ if (this.renderer) {
+ this.renderer.refresh({
+ skipIndexation: true
+ })
+ }
+ if (this.focusedFoundNodeId) {
+ this.moveCameraToNode(this.focusedFoundNodeId)
+ }
}
},
mounted() {
@@ -839,6 +855,15 @@ export default {
return drawOnCanvas(this.renderer, {
backgroundColor: this.settings.style.backgroundColor
})
+ },
+ moveCameraToNode(nodeId) {
+ if (!this.graph) {
+ return
+ }
+ const nodePosition = this.renderer.getNodeDisplayData(nodeId)
+ this.renderer.getCamera().animate(nodePosition, {
+ duration: 500
+ })
}
}
}
diff --git a/src/components/Graph/GraphSearchPanel.vue b/src/components/Graph/GraphSearchPanel.vue
index 848800a..92da8c0 100644
--- a/src/components/Graph/GraphSearchPanel.vue
+++ b/src/components/Graph/GraphSearchPanel.vue
@@ -5,6 +5,7 @@
:title="panelOpen ? 'Close search panel' : 'Open search panel'"
variant="secondary"
:class="{ 'close-search-panel': panelOpen }"
+ data-test="toggle-search-panel-visibility-btn"
@click="panelOpen = !panelOpen"
/>
@@ -21,12 +22,14 @@
icon="myicon myicon-match-case"
title="Match case"
:active="matchCase"
+ data-test="match-case-btn"
@click="matchCase = !matchCase"
/>
@@ -42,7 +45,7 @@
v-if="
nodesMatchingSearchQuery?.length > 0 && currentFoundNodeIndex !== -1
"
- >{{ currentFoundNodeIndex + 1 }} /
+ >{{ currentFoundNodeIndex + 1 }} of
{{ nodesMatchingSearchQuery.length }}
@@ -50,6 +53,7 @@
v-show="
nodesMatchingSearchQuery && nodesMatchingSearchQuery.length === 0
"
+ data-test="no-results-text"
>
No results
@@ -58,6 +62,7 @@
icon="myicon myicon-arrow"
title="Previous match"
:disabled="arrowsDisabled"
+ data-test="previous-match-btn"
@click="toNextFoundNode(-1)"
/>
@@ -66,6 +71,7 @@
title="Next match"
:disabled="arrowsDisabled"
class="next-match-btn"
+ data-test="next-match-btn"
@click="toNextFoundNode(1)"
/>
@@ -92,12 +98,7 @@ export default {
TextField,
IconButton
},
- props: [
- 'graph',
- 'renderer',
- 'nodesMatchingSearchQuery',
- 'focusedFoundNodeId'
- ],
+ props: ['graph', 'nodesMatchingSearchQuery', 'focusedFoundNodeId'],
data() {
return {
searchQuery: '',
@@ -136,7 +137,7 @@ export default {
}).format(count)
: count
- return `${formattedCount} nodes found`
+ return `${formattedCount} ${count === 1 ? 'node' : 'nodes'} found`
}
},
watch: {
@@ -150,9 +151,7 @@ export default {
},
matchCase() {
this.search()
- },
- nodesMatchingSearchQuery: 'refresh',
- focusedFoundNodeId: 'refresh'
+ }
},
methods: {
onSearchInput: time.debounce(function () {
@@ -179,9 +178,6 @@ export default {
})
},
toNextFoundNode(direction = 1) {
- if (this.nodesMatchingSearchQuery.length === 0) {
- return
- }
this.currentFoundNodeIndex =
(this.currentFoundNodeIndex +
direction +
@@ -189,28 +185,6 @@ export default {
this.nodesMatchingSearchQuery.length
const nodeId = this.nodesMatchingSearchQuery[this.currentFoundNodeIndex]
this.$emit('update:focusedFoundNodeId', nodeId)
- if (this.renderer) {
- this.renderer.refresh({
- skipIndexation: true
- })
- }
- this.moveCameraToNode(nodeId)
- },
- moveCameraToNode(nodeId) {
- if (!this.graph) {
- return
- }
- const nodePosition = this.renderer.getNodeDisplayData(nodeId)
- this.renderer.getCamera().animate(nodePosition, {
- duration: 500
- })
- },
- refresh() {
- if (this.renderer) {
- this.renderer.refresh({
- skipIndexation: true
- })
- }
}
}
}
diff --git a/tests/components/Graph/GraphSearchPanel.spec.js b/tests/components/Graph/GraphSearchPanel.spec.js
new file mode 100644
index 0000000..ffbe33c
--- /dev/null
+++ b/tests/components/Graph/GraphSearchPanel.spec.js
@@ -0,0 +1,236 @@
+import { expect } from 'chai'
+import { mount } from '@vue/test-utils'
+import Graph from 'graphology'
+import GraphSearchPanel from '@/components/Graph/GraphSearchPanel.vue'
+import sinon from 'sinon'
+import { nextTick } from 'vue'
+
+describe('GraphSearchPanel.vue', () => {
+ const buildGraph = () => {
+ const graph = new Graph()
+ graph.addNode('node-1', { label: 'Alpha' })
+ graph.addNode('node-2', { label: 'Beta' })
+ graph.addNode('node-3', { label: 'Gamma' })
+ graph.addNode('node-4', { label: 'Foo' })
+ return graph
+ }
+
+ it('matches labels and emits node ids for navigation', async () => {
+ const clock = sinon.useFakeTimers()
+ const wrapper = mount(GraphSearchPanel, {
+ props: {
+ graph: buildGraph(),
+ nodesMatchingSearchQuery: null,
+ focusedFoundNodeId: null,
+ 'onUpdate:nodesMatchingSearchQuery': nodes => {
+ wrapper.setProps({ nodesMatchingSearchQuery: nodes })
+ },
+ 'onUpdate:focusedFoundNodeId': nodeId => {
+ wrapper.setProps({ focusedFoundNodeId: nodeId })
+ }
+ }
+ })
+
+ await wrapper.find('.query-input input').setValue('a')
+ clock.tick(500)
+
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([
+ 'node-1',
+ 'node-2',
+ 'node-3'
+ ])
+ expect(wrapper.emitted('update:focusedFoundNodeId')[0][0]).to.equal(null)
+ await nextTick()
+ expect(wrapper.text()).to.contain('3 nodes found')
+
+ await wrapper.find('[data-test="next-match-btn"]').trigger('click')
+ expect(wrapper.emitted('update:focusedFoundNodeId')[1][0]).to.equal(
+ 'node-1'
+ )
+ await nextTick()
+ expect(wrapper.text()).to.contain('1 of 3')
+
+ await wrapper.find('[data-test="next-match-btn"]').trigger('click')
+ expect(wrapper.emitted('update:focusedFoundNodeId')[2][0]).to.equal(
+ 'node-2'
+ )
+ await nextTick()
+ expect(wrapper.text()).to.contain('2 of 3')
+
+ await wrapper.find('[data-test="previous-match-btn"]').trigger('click')
+ expect(wrapper.emitted('update:focusedFoundNodeId')[3][0]).to.equal(
+ 'node-1'
+ )
+
+ await nextTick()
+ expect(wrapper.text()).to.contain('1 of 3')
+
+ await wrapper.find('[data-test="previous-match-btn"]').trigger('click')
+ expect(wrapper.emitted('update:focusedFoundNodeId')[4][0]).to.equal(
+ 'node-3'
+ )
+
+ await nextTick()
+ expect(wrapper.text()).to.contain('3 of 3')
+
+ await wrapper.find('[data-test="next-match-btn"]').trigger('click')
+ expect(wrapper.emitted('update:focusedFoundNodeId')[5][0]).to.equal(
+ 'node-1'
+ )
+ await nextTick()
+ expect(wrapper.text()).to.contain('1 of 3')
+
+ await wrapper.find('.query-input input').setValue('Foo')
+ clock.tick(500)
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[1][0]).to.eql([
+ 'node-4'
+ ])
+ expect(wrapper.emitted('update:focusedFoundNodeId')[6][0]).to.equal(null)
+
+ await wrapper.find('.query-input input').setValue('')
+ clock.tick(500)
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[2][0]).to.eql(
+ null
+ )
+ expect(wrapper.emitted('update:focusedFoundNodeId')[7][0]).to.equal(null)
+ clock.restore()
+ })
+
+ it('matches case and uses reg exp', async () => {
+ const clock = sinon.useFakeTimers()
+ const wrapper = mount(GraphSearchPanel, {
+ props: {
+ graph: buildGraph(),
+ nodesMatchingSearchQuery: null,
+ focusedFoundNodeId: null,
+ 'onUpdate:nodesMatchingSearchQuery': nodes => {
+ wrapper.setProps({ nodesMatchingSearchQuery: nodes })
+ },
+ 'onUpdate:focusedFoundNodeId': nodeId => {
+ wrapper.setProps({ focusedFoundNodeId: nodeId })
+ }
+ }
+ })
+
+ await wrapper.find('.query-input input').setValue('A')
+ clock.tick(500)
+
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([
+ 'node-1',
+ 'node-2',
+ 'node-3'
+ ])
+
+ const matchCaseBtn = wrapper.find('[data-test="match-case-btn"]')
+ const useRegExpBtn = wrapper.find('[data-test="use-reg-exp-btn"]')
+
+ await matchCaseBtn.trigger('click')
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[1][0]).to.eql([
+ 'node-1'
+ ])
+
+ await wrapper.find('.query-input input').setValue('a.+a$')
+ clock.tick(500)
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[2][0]).to.eql([])
+
+ await useRegExpBtn.trigger('click')
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[3][0]).to.eql([
+ 'node-3'
+ ])
+
+ await matchCaseBtn.trigger('click')
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[4][0]).to.eql([
+ 'node-1',
+ 'node-3'
+ ])
+
+ clock.restore()
+ })
+
+ it('shows No items found and disables navigation arrows', async () => {
+ const clock = sinon.useFakeTimers()
+ const wrapper = mount(GraphSearchPanel, {
+ props: {
+ graph: buildGraph(),
+ nodesMatchingSearchQuery: null,
+ focusedFoundNodeId: null,
+ 'onUpdate:nodesMatchingSearchQuery': nodes => {
+ wrapper.setProps({ nodesMatchingSearchQuery: nodes })
+ },
+ 'onUpdate:focusedFoundNodeId': nodeId => {
+ wrapper.setProps({ focusedFoundNodeId: nodeId })
+ }
+ }
+ })
+
+ await wrapper.find('.query-input input').setValue('baz')
+ clock.tick(500)
+
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([])
+ expect(wrapper.emitted('update:focusedFoundNodeId')[0][0]).to.equal(null)
+ await nextTick()
+ expect(wrapper.find('[data-test="no-results-text"]').isVisible()).to.be.true
+
+ await expect(
+ wrapper.find('[data-test="next-match-btn"]').attributes('disabled')
+ ).to.not.equal(undefined)
+ await expect(
+ wrapper.find('[data-test="previous-match-btn"]').attributes('disabled')
+ ).to.not.equal(undefined)
+ clock.restore()
+ })
+
+ it('handles invalid regular expressions by clearing matches', async () => {
+ const clock = sinon.useFakeTimers()
+ const wrapper = mount(GraphSearchPanel, {
+ props: {
+ graph: buildGraph(),
+ nodesMatchingSearchQuery: ['node-1'],
+ focusedFoundNodeId: 'node-1'
+ }
+ })
+
+ await wrapper.find('.query-input input').setValue('[')
+ clock.tick(500)
+
+ expect(wrapper.emitted('update:nodesMatchingSearchQuery')[0][0]).to.eql([])
+ const useRegExpBtn = wrapper.find('[data-test="use-reg-exp-btn"]')
+
+ await useRegExpBtn.trigger('click')
+
+ expect(
+ wrapper.emitted('update:nodesMatchingSearchQuery').at(-1)[0]
+ ).to.equal(null)
+ expect(wrapper.emitted('update:focusedFoundNodeId').at(-1)[0]).to.equal(
+ null
+ )
+ expect(wrapper.text()).to.contain('Invalid regular expression')
+
+ clock.restore()
+ })
+
+ it('toggles the search panel visibility', async () => {
+ const wrapper = mount(GraphSearchPanel, {
+ props: {
+ graph: buildGraph(),
+ nodesMatchingSearchQuery: null,
+ focusedFoundNodeId: null
+ },
+ attachTo: document.body
+ })
+
+ const searchControls = wrapper.find('.graph-search-panel--search-controls')
+ expect(searchControls.isVisible()).to.be.false
+
+ const toggleButton = wrapper.find(
+ '[data-test="toggle-search-panel-visibility-btn"]'
+ )
+ await toggleButton.trigger('click')
+ expect(searchControls.isVisible()).to.be.true
+
+ await toggleButton.trigger('click')
+ expect(wrapper.vm.panelOpen).to.be.false
+
+ wrapper.unmount()
+ })
+})