Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type cytoscape from 'cytoscape';
import { filterValidElements } from './cytoscape';

const node = (id: string): cytoscape.ElementDefinition => ({
data: { id },
});

const edge = (id: string, source: string, target: string): cytoscape.ElementDefinition => ({
data: { id, source, target },
});

describe('filterValidElements', () => {
it('returns all elements when every edge references existing nodes', () => {
const elements = [node('a'), node('b'), edge('a->b', 'a', 'b')];

expect(filterValidElements(elements)).toEqual(elements);
});

it('removes edges whose source node does not exist', () => {
const elements = [node('a'), edge('x->a', 'x', 'a')];

expect(filterValidElements(elements)).toEqual([node('a')]);
});

it('removes edges whose target node does not exist', () => {
const elements = [node('a'), edge('a->x', 'a', 'x')];

expect(filterValidElements(elements)).toEqual([node('a')]);
});

it('removes edges where both source and target nodes are missing', () => {
const elements = [node('a'), edge('x->y', 'x', 'y')];

expect(filterValidElements(elements)).toEqual([node('a')]);
});

it('keeps nodes that have no edges', () => {
const elements = [node('a'), node('b')];

expect(filterValidElements(elements)).toEqual(elements);
});

it('returns an empty array when given an empty array', () => {
expect(filterValidElements([])).toEqual([]);
});

it('handles a mix of valid and orphaned edges', () => {
const elements = [
node('a'),
node('b'),
edge('a->b', 'a', 'b'),
edge('a->missing', 'a', 'missing'),
edge('missing->b', 'missing', 'b'),
];

expect(filterValidElements(elements)).toEqual([node('a'), node('b'), edge('a->b', 'a', 'b')]);
});

it('keeps multiple valid edges between the same nodes', () => {
const elements = [node('a'), node('b'), edge('e1', 'a', 'b'), edge('e2', 'a', 'b')];

expect(filterValidElements(elements)).toEqual(elements);
});

it('handles elements with only edges and no nodes', () => {
const elements = [edge('a->b', 'a', 'b')];

expect(filterValidElements(elements)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ cytoscape.use(dagre);

export const CytoscapeContext = createContext<cytoscape.Core | undefined>(undefined);

/**
* Filters out edges whose source or target node is not present in the
* elements list. This prevents Cytoscape from throwing when the graph
* data contains inconsistencies (e.g. an edge referencing a node that
* was removed by server-side grouping).
*/
export const filterValidElements = (
elements: cytoscape.ElementDefinition[]
): cytoscape.ElementDefinition[] => {
const nodeIds = new Set(
elements.filter((el) => !el.data.source && !el.data.target).map((el) => el.data.id)
);
return elements.filter((el) => {
if (el.data.source || el.data.target) {
return nodeIds.has(el.data.source) && nodeIds.has(el.data.target);
}
return true;
});
};

export interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
Expand Down Expand Up @@ -64,16 +84,18 @@ function CytoscapeComponent({ children, elements, height, serviceName, style }:
// We do a fit if we're going from 0 to >0 elements
const fit = cy.elements().length === 0;

cy.add(elements);
const validElements = filterValidElements(elements);

cy.add(validElements);
// Remove any old elements that don't exist in the new set of elements.
const elementIds = elements.map((element) => element.data.id);
const elementIds = validElements.map((element) => element.data.id);
cy.elements().forEach((element) => {
if (!elementIds.includes(element.data('id'))) {
cy.remove(element);
} else {
// Doing an "add" with an element with the same id will keep the original
// element. Set the data with the new element data.
const newElement = elements.find((el) => el.data.id === element.id());
const newElement = validElements.find((el) => el.data.id === element.id());
element.data(newElement?.data ?? element.data());
}
});
Expand Down
Loading