Skip to content
Open
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
118 changes: 118 additions & 0 deletions src/components/designer/DesignerPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import cytoscape, { type Core } from 'cytoscape';
import { syncGraphElements } from './DesignerPreview';

// The designer preview renders into a canvas, which jsdom does not provide, so
// the sync logic is exercised against a headless Cytoscape instance instead.

type GraphOntology = Parameters<typeof syncGraphElements>[1];

function entity(id: string) {
return { id, name: id, icon: '📦', color: '#0078D4' };
}

function relationship(id: string, from: string, to: string, name = 'relatesTo') {
return { id, name, from, to, cardinality: 'one-to-many' };
}

function graphOf(ontology: GraphOntology): Core {
const cy = cytoscape({ headless: true });
syncGraphElements(cy, ontology);
return cy;
}

describe('syncGraphElements', () => {
it('draws an edge between the entities its relationship points at', () => {
const cy = graphOf({
entityTypes: [entity('book'), entity('author')],
relationships: [relationship('rel-1', 'book', 'author', 'writtenBy')],
});

const edge = cy.getElementById('rel-1');
expect(edge.source().id()).toBe('book');
expect(edge.target().id()).toBe('author');
});

it('moves the edge when the relationship is re-pointed at another source', () => {
const entityTypes = [entity('book'), entity('author'), entity('member')];
const cy = graphOf({
entityTypes,
relationships: [relationship('rel-1', 'member', 'author', 'writtenBy')],
});

syncGraphElements(cy, {
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'writtenBy')],
});

const edge = cy.getElementById('rel-1');
expect(edge.source().id()).toBe('book');
expect(edge.target().id()).toBe('author');
expect(cy.edges()).toHaveLength(1);
});

it('moves the edge when the relationship is re-pointed at another target', () => {
const entityTypes = [entity('book'), entity('author'), entity('member')];
const cy = graphOf({
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'borrowedBy')],
});

syncGraphElements(cy, {
entityTypes,
relationships: [relationship('rel-1', 'book', 'member', 'borrowedBy')],
});

const edge = cy.getElementById('rel-1');
expect(edge.source().id()).toBe('book');
expect(edge.target().id()).toBe('member');
expect(cy.edges()).toHaveLength(1);
});

it('keeps the endpoints and updates the label when only the name changes', () => {
const entityTypes = [entity('book'), entity('author')];
const cy = graphOf({
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'writtenBy')],
});

syncGraphElements(cy, {
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'authoredBy')],
});

const edge = cy.getElementById('rel-1');
expect(edge.data('label')).toBe('authoredBy');
expect(edge.source().id()).toBe('book');
expect(edge.target().id()).toBe('author');
});

it('leaves the positions of existing nodes untouched when re-pointing', () => {
const entityTypes = [entity('book'), entity('author'), entity('member')];
const cy = graphOf({
entityTypes,
relationships: [relationship('rel-1', 'member', 'author', 'writtenBy')],
});
cy.getElementById('book').position({ x: 100, y: 200 });

syncGraphElements(cy, {
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'writtenBy')],
});

expect(cy.getElementById('book').position()).toEqual({ x: 100, y: 200 });
});

it('drops the edge once its relationship is deleted', () => {
const entityTypes = [entity('book'), entity('author')];
const cy = graphOf({
entityTypes,
relationships: [relationship('rel-1', 'book', 'author', 'writtenBy')],
});

syncGraphElements(cy, { entityTypes, relationships: [] });

expect(cy.getElementById('rel-1')).toHaveLength(0);
expect(cy.nodes()).toHaveLength(2);
});
});
122 changes: 67 additions & 55 deletions src/components/designer/DesignerPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,73 +152,85 @@ function GraphPreview({ ontology, theme, onSelectEntity, onSelectRelationship }:

// Incrementally sync nodes & edges without full relayout
useEffect(() => {
const cy = cyRef.current;
if (!cy) return;
if (cyRef.current) syncGraphElements(cyRef.current, ontology);
}, [ontology]);

const currentNodeIds = new Set(cy.nodes().map((n) => n.id()));
const currentEdgeIds = new Set(cy.edges().map((e) => e.id()));
const desiredNodeIds = new Set(ontology.entityTypes.map((e) => e.id));
const desiredEdgeIds = new Set(ontology.relationships.map((r) => r.id));
return <div ref={containerRef} className="designer-graph-container" />;
}

// Remove deleted elements
const toRemove = cy.elements().filter((ele) => {
const id = ele.id();
return ele.isNode() ? !desiredNodeIds.has(id) : !desiredEdgeIds.has(id);
});
if (toRemove.length) toRemove.remove();
/** Sync an existing graph to match the ontology, keeping the positions of
* nodes that are still present instead of laying the whole graph out again.
*
* Exported so it can be tested against a headless Cytoscape instance —
* rendering the component needs a canvas, which jsdom does not provide. */
export function syncGraphElements(cy: Core, ontology: GraphPreviewProps['ontology']) {
const currentNodeIds = new Set(cy.nodes().map((n) => n.id()));
const currentEdgeIds = new Set(cy.edges().map((e) => e.id()));
const desiredNodeIds = new Set(ontology.entityTypes.map((e) => e.id));
const desiredEdgeIds = new Set(ontology.relationships.map((r) => r.id));

// Add new nodes
const newNodes: { data: Record<string, string> }[] = [];
for (const entity of ontology.entityTypes) {
if (!currentNodeIds.has(entity.id)) {
newNodes.push({ data: { id: entity.id, label: `${entity.icon} ${entity.name}`, color: entity.color } });
}
}
// Remove deleted elements
const toRemove = cy.elements().filter((ele) => {
const id = ele.id();
return ele.isNode() ? !desiredNodeIds.has(id) : !desiredEdgeIds.has(id);
});
if (toRemove.length) toRemove.remove();

// Add new edges
const newEdges: { data: Record<string, string> }[] = [];
for (const rel of ontology.relationships) {
if (!currentEdgeIds.has(rel.id)) {
newEdges.push({ data: { id: rel.id, source: rel.from, target: rel.to, label: rel.name } });
}
// Add new nodes
const newNodes: { data: Record<string, string> }[] = [];
for (const entity of ontology.entityTypes) {
if (!currentNodeIds.has(entity.id)) {
newNodes.push({ data: { id: entity.id, label: `${entity.icon} ${entity.name}`, color: entity.color } });
}
}

if (newNodes.length || newEdges.length) {
cy.add([...newNodes, ...newEdges]);
// Only lay out NEW nodes near existing ones, keeping existing positions
if (newNodes.length) {
const newEles = cy.collection();
for (const n of newNodes) {
newEles.merge(cy.getElementById(n.data.id));
}
// Position new nodes near the center of the viewport
const { x1, y1, w, h } = cy.extent();
const cx = x1 + w / 2;
const cy2 = y1 + h / 2;
newEles.forEach((ele, i) => {
ele.position({ x: cx + (i - newNodes.length / 2) * 80, y: cy2 });
});
}
cy.fit(undefined, 40);
// Add new edges
const newEdges: { data: Record<string, string> }[] = [];
for (const rel of ontology.relationships) {
if (!currentEdgeIds.has(rel.id)) {
newEdges.push({ data: { id: rel.id, source: rel.from, target: rel.to, label: rel.name } });
}
}

// Update cosmetic data on existing elements
for (const entity of ontology.entityTypes) {
const node = cy.getElementById(entity.id);
if (node.length) {
node.data('label', `${entity.icon} ${entity.name}`);
node.data('color', entity.color);
if (newNodes.length || newEdges.length) {
cy.add([...newNodes, ...newEdges]);
// Only lay out NEW nodes near existing ones, keeping existing positions
if (newNodes.length) {
const newEles = cy.collection();
for (const n of newNodes) {
newEles.merge(cy.getElementById(n.data.id));
}
// Position new nodes near the center of the viewport
const { x1, y1, w, h } = cy.extent();
const cx = x1 + w / 2;
const cy2 = y1 + h / 2;
newEles.forEach((ele, i) => {
ele.position({ x: cx + (i - newNodes.length / 2) * 80, y: cy2 });
});
}
for (const rel of ontology.relationships) {
const edge = cy.getElementById(rel.id);
if (edge.length) {
edge.data('label', rel.name);
}
cy.fit(undefined, 40);
}

// Update cosmetic data on existing nodes
for (const entity of ontology.entityTypes) {
const node = cy.getElementById(entity.id);
if (node.length) {
node.data('label', `${entity.icon} ${entity.name}`);
node.data('color', entity.color);
}
}, [ontology]);
}

return <div ref={containerRef} className="designer-graph-container" />;
// Rebuild an edge whose endpoints moved — Cytoscape treats source/target as immutable.
for (const rel of ontology.relationships) {
const edge = cy.getElementById(rel.id);
if (!edge.length) continue;
if (edge.data('source') !== rel.from || edge.data('target') !== rel.to) {
edge.remove();
cy.add({ data: { id: rel.id, source: rel.from, target: rel.to, label: rel.name } });
continue;
}
edge.data('label', rel.name);
}
}

// ─── RDF tab ─────────────────────────────────────────────────────────────────
Expand Down