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() + }) +})