#49 feat: desplegable en seccion de provenance con leyenda de colores y titulo sin historia
This commit is contained in:
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
|
|||||||
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
|
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
|
||||||
- **Genérico reutilizable:**
|
- **Genérico reutilizable:**
|
||||||
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
|
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
|
||||||
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores) y columnas por nivel (x fijo por tipo; la física ordena la y); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceDetailModal.vue` y `ProvenanceSection.vue`
|
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto — patrón reutilizable para futuros bloques como el mapa leaflet. `ProvenanceDetailModal.vue`
|
||||||
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
|
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
|
||||||
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
|
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
|
||||||
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
|
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
|
||||||
|
|||||||
@@ -18,9 +18,18 @@
|
|||||||
:key="option.value"
|
:key="option.value"
|
||||||
v-model="selectedKinds"
|
v-model="selectedKinds"
|
||||||
density="compact"
|
density="compact"
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
/>
|
>
|
||||||
|
<template #label>
|
||||||
|
<span class="d-flex align-center">
|
||||||
|
<span
|
||||||
|
class="legend-dot"
|
||||||
|
:style="{ backgroundColor: option.color }"
|
||||||
|
/>
|
||||||
|
{{ option.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</v-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-alert
|
<v-alert
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
import VisChart from '@/components/graph/VisChart.vue'
|
import VisChart from '@/components/graph/VisChart.vue'
|
||||||
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
|
import ProvenanceDetailModal from './ProvenanceDetailModal.vue'
|
||||||
import { buildProvenanceGraph, hasAnySupplier } from './provenance-graph'
|
import { buildProvenanceGraph, hasAnySupplier } from './provenance-graph'
|
||||||
import { chartOptions, toVisEdges, toVisNodes } from './provenance-vis'
|
import { chartOptions, KIND_COLORS, toVisEdges, toVisNodes } from './provenance-vis'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -67,12 +76,12 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ value: 'product', label: 'Productos' },
|
{ value: 'product', label: 'Productos', color: KIND_COLORS.product },
|
||||||
{ value: 'supplier', label: 'Proveedores' },
|
{ value: 'supplier', label: 'Proveedores', color: KIND_COLORS.supplier },
|
||||||
{ value: 'organization', label: 'Organizaciones' },
|
{ value: 'organization', label: 'Organizaciones', color: KIND_COLORS.organization },
|
||||||
{ value: 'municipality', label: 'Municipios' },
|
{ value: 'municipality', label: 'Municipios', color: KIND_COLORS.municipality },
|
||||||
{ value: 'department', label: 'Departamentos' },
|
{ value: 'department', label: 'Departamentos', color: KIND_COLORS.department },
|
||||||
{ value: 'country', label: 'País' },
|
{ value: 'country', label: 'País', color: KIND_COLORS.country },
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedKinds = ref(['product', 'supplier'])
|
const selectedKinds = ref(['product', 'supplier'])
|
||||||
@@ -93,3 +102,13 @@
|
|||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.legend-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="provenance && provenance.length > 0">
|
<div v-if="provenance && provenance.length > 0">
|
||||||
<v-divider class="my-4" />
|
<v-divider class="my-4" />
|
||||||
<h3 class="text-h6 font-weight-bold mb-1">Origen e historia de los productos</h3>
|
<div
|
||||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
class="d-flex align-center justify-space-between cursor-pointer"
|
||||||
Conoce quiénes producen los productos que compras y de qué territorios provienen.
|
data-test="provenance-toggle"
|
||||||
</p>
|
role="button"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
>
|
||||||
|
<h3 class="text-h6 font-weight-bold">
|
||||||
|
Origen de los productos
|
||||||
|
</h3>
|
||||||
|
<v-btn
|
||||||
|
data-test="provenance-toggle-button"
|
||||||
|
density="comfortable"
|
||||||
|
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||||
|
variant="text"
|
||||||
|
@click.stop="expanded = !expanded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProvenanceGraph :provenance="provenance" />
|
<v-expand-transition>
|
||||||
|
<div v-if="expanded">
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Conoce quiénes producen los productos que compras y de qué territorios provienen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ProvenanceGraph :provenance="provenance" />
|
||||||
|
</div>
|
||||||
|
</v-expand-transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import ProvenanceGraph from './ProvenanceGraph.vue'
|
import ProvenanceGraph from './ProvenanceGraph.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
@@ -19,4 +41,6 @@
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const COLUMN_X = {
|
|||||||
country: 560,
|
country: 560,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const KIND_COLORS = COLORS
|
||||||
|
|
||||||
export function toVisNodes (nodes) {
|
export function toVisNodes (nodes) {
|
||||||
const products = nodes.filter(node => node.kind === 'product')
|
const products = nodes.filter(node => node.kind === 'product')
|
||||||
const productY = new Map()
|
const productY = new Map()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function visChart (wrapper) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkbox (wrapper, label) {
|
function checkbox (wrapper, label) {
|
||||||
return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.props('label') === label)
|
return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.text().trim() === label)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setKinds (wrapper, kinds) {
|
async function setKinds (wrapper, kinds) {
|
||||||
@@ -75,10 +75,20 @@ describe('ProvenanceGraph', () => {
|
|||||||
it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
|
it('muestra un checkbox por cada nivel con productos y proveedores activos', () => {
|
||||||
const wrapper = mountGraph()
|
const wrapper = mountGraph()
|
||||||
|
|
||||||
const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.props('label'))
|
const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.text().trim())
|
||||||
expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
|
expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dibuja un círculo de color junto a cada checkbox como leyenda', () => {
|
||||||
|
const wrapper = mountGraph()
|
||||||
|
|
||||||
|
const dots = wrapper.findAll('.legend-dot')
|
||||||
|
expect(dots).toHaveLength(6)
|
||||||
|
expect(dots[0].element.style.backgroundColor).toBe('rgb(38, 166, 154)')
|
||||||
|
expect(dots[1].element.style.backgroundColor).toBe('rgb(66, 165, 245)')
|
||||||
|
expect(dots[2].element.style.backgroundColor).toBe('rgb(255, 183, 77)')
|
||||||
|
})
|
||||||
|
|
||||||
it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
|
it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => {
|
||||||
const wrapper = mountGraph()
|
const wrapper = mountGraph()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ vi.mock('@/components/graph/VisChart.vue', () => ({
|
|||||||
default: {
|
default: {
|
||||||
name: 'VisChart',
|
name: 'VisChart',
|
||||||
template: '<div class="vis-chart-stub" />',
|
template: '<div class="vis-chart-stub" />',
|
||||||
props: ['nodes', 'edges', 'height', 'options'],
|
props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'],
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -47,16 +47,41 @@ describe('ProvenanceSection', () => {
|
|||||||
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('muestra el gráfico unificado con su título cuando hay provenance', () => {
|
it('muestra el título y oculta el gráfico por defecto', () => {
|
||||||
const wrapper = mountSection()
|
const wrapper = mountSection()
|
||||||
|
|
||||||
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
|
expect(wrapper.text()).toContain('Origen de los productos')
|
||||||
expect(wrapper.text()).toContain('Origen e historia de los productos')
|
expect(wrapper.text()).not.toContain('historia')
|
||||||
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pasa el provenance al gráfico', () => {
|
it('muestra el gráfico al hacer clic en el título y lo oculta al volver a hacer clic', async () => {
|
||||||
const wrapper = mountSection()
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
|
||||||
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('también despliega y repliega con el botón de chevron', async () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
const button = wrapper.find('[data-test="provenance-toggle-button"]')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true)
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasa el provenance al gráfico al desplegarlo', async () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="provenance-toggle"]').trigger('click')
|
||||||
|
|
||||||
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
|
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user