#49 feat: componente genérico CytoscapeChart con evento select
This commit is contained in:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"core-js": "^3.37.1",
|
"core-js": "^3.37.1",
|
||||||
|
"cytoscape": "^3.34.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"roboto-fontface": "*",
|
"roboto-fontface": "*",
|
||||||
"vee-validate": "^4.14.6",
|
"vee-validate": "^4.14.6",
|
||||||
@@ -2752,6 +2753,14 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cytoscape": {
|
||||||
|
"version": "3.34.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.1.tgz",
|
||||||
|
"integrity": "sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"core-js": "^3.37.1",
|
"core-js": "^3.37.1",
|
||||||
|
"cytoscape": "^3.34.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"roboto-fontface": "*",
|
"roboto-fontface": "*",
|
||||||
"vee-validate": "^4.14.6",
|
"vee-validate": "^4.14.6",
|
||||||
|
|||||||
75
src/components/graph/CytoscapeChart.vue
Normal file
75
src/components/graph/CytoscapeChart.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="cytoscape-chart"
|
||||||
|
:style="{ height: `${height}px` }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import cytoscape from 'cytoscape'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
elements: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ name: 'preset' }),
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 400,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const containerRef = ref(null)
|
||||||
|
let cy = null
|
||||||
|
|
||||||
|
function runLayout () {
|
||||||
|
cy.layout(props.layout).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
cy = cytoscape({
|
||||||
|
container: containerRef.value,
|
||||||
|
elements: props.elements,
|
||||||
|
style: props.styles,
|
||||||
|
layout: props.layout,
|
||||||
|
})
|
||||||
|
cy.on('tap', 'node', event => {
|
||||||
|
emit('select', event.target.data())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.elements,
|
||||||
|
elements => {
|
||||||
|
if (!cy) return
|
||||||
|
cy.elements().remove()
|
||||||
|
cy.add(elements)
|
||||||
|
runLayout()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (cy) cy.destroy()
|
||||||
|
cy = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cytoscape-chart {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
tests/unit/components/graph/CytoscapeChart.spec.js
Normal file
94
tests/unit/components/graph/CytoscapeChart.spec.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import CytoscapeChart from '@/components/graph/CytoscapeChart.vue'
|
||||||
|
|
||||||
|
const tapHandler = { current: null }
|
||||||
|
const cy = {
|
||||||
|
on: vi.fn((event, selector, handler) => {
|
||||||
|
if (event === 'tap') tapHandler.current = handler
|
||||||
|
}),
|
||||||
|
elements: vi.fn(() => ({ remove: vi.fn() })),
|
||||||
|
add: vi.fn(),
|
||||||
|
layout: vi.fn(() => ({ run: vi.fn() })),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('cytoscape', () => ({
|
||||||
|
default: vi.fn(() => cy),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import cytoscape from 'cytoscape'
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
{ data: { id: 'product:1', kind: 'product', label: 'Panela' }, position: { x: 100, y: 100 } },
|
||||||
|
{ data: { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, position: { x: 320, y: 100 } },
|
||||||
|
{ data: { id: 'e1', source: 'product:1', target: 'supplier:5' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const styles = [{ selector: 'node', style: { 'background-color': '#eee' } }]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
tapHandler.current = null
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountChart (props = {}) {
|
||||||
|
return mount(CytoscapeChart, {
|
||||||
|
props: { elements, styles, ...props },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CytoscapeChart', () => {
|
||||||
|
it('crea la instancia de cytoscape con elements, styles y layout', () => {
|
||||||
|
mountChart()
|
||||||
|
|
||||||
|
expect(cytoscape).toHaveBeenCalledTimes(1)
|
||||||
|
expect(cytoscape).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
elements,
|
||||||
|
style: styles,
|
||||||
|
layout: { name: 'preset' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('permite sobreescribir el layout con el prop layout', () => {
|
||||||
|
mountChart({ layout: { name: 'breadthfirst' } })
|
||||||
|
|
||||||
|
expect(cytoscape).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ layout: { name: 'breadthfirst' } })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emite select con los datos del nodo al hacer tap', () => {
|
||||||
|
const wrapper = mountChart()
|
||||||
|
|
||||||
|
tapHandler.current({ target: { data: () => elements[0].data } })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')).toEqual([[elements[0].data]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('actualiza la instancia cuando cambian los elements', async () => {
|
||||||
|
const wrapper = mountChart()
|
||||||
|
const next = [{ data: { id: 'organization:3', kind: 'organization', label: 'Red' } }]
|
||||||
|
|
||||||
|
await wrapper.setProps({ elements: next })
|
||||||
|
|
||||||
|
expect(cy.elements).toHaveBeenCalled()
|
||||||
|
expect(cy.add).toHaveBeenCalledWith(next)
|
||||||
|
expect(cy.layout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('destruye la instancia al desmontarse', () => {
|
||||||
|
const wrapper = mountChart()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(cy.destroy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no escucha eventos mientras no exista la instancia', () => {
|
||||||
|
mountChart()
|
||||||
|
expect(cy.on).toHaveBeenCalledWith('tap', 'node', expect.any(Function))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user