Skip to content

Commit

Permalink
Merge pull request #369 from amosproj/node-details
Browse files Browse the repository at this point in the history
Add details view for graphPage
  • Loading branch information
derwehr authored Jul 7, 2021
2 parents 6a84a6b + d7c0d1f commit 30da220
Show file tree
Hide file tree
Showing 19 changed files with 830 additions and 39 deletions.
68 changes: 68 additions & 0 deletions frontend/cypress/fixtures/QueryServiceFake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { injectable } from 'inversify';
import { from, Observable } from 'rxjs';
import { QueryService } from '../../src/services/query';
import {
Edge,
EdgeDescriptor,
Node,
NodeDescriptor,
} from '../../src/shared/entities';
import {
QueryBase,
QueryResult,
CountQueryResult,
} from '../../src/shared/queries';
import { dummyEdges, dummyNodes } from './entityDetails/entityDetails';

@injectable()
export default class QueryServiceFake extends QueryService {
public queryAll(query?: QueryBase): Observable<QueryResult> {
throw new Error('Method not implemented.');
}
public getEdgesById(ids: number[]): Observable<Edge[]>;
public getEdgesById(descriptors: EdgeDescriptor[]): Observable<Edge[]>;
public getEdgesById(descriptors: any): Observable<Edge[]> {
throw new Error('Method not implemented.');
}
public getNodesById(ids: number[]): Observable<Node[]>;
public getNodesById(descriptors: NodeDescriptor[]): Observable<Node[]>;
public getNodesById(descriptors: any): Observable<Node[]> {
throw new Error('Method not implemented.');
}
public getNumberOfEntities(): Observable<CountQueryResult> {
throw new Error('Method not implemented.');
}
/**
* returns an observable containing a node by id from {@link dummies}, or null if not found.
* @param id the id of the node to return.
*/
public getNodeById(id: number | NodeDescriptor): Observable<Node | null> {
let node: Node | null = null;
for (const n of dummyNodes) {
if (typeof id === 'number' && n.id === id) {
node = n;
break;
}
}

if (!node) {
return from([null]);
}

return from([node]);
}
public getEdgeById(id: number | EdgeDescriptor): Observable<Edge | null> {
let edge: Edge | null = null;
for (const e of dummyEdges) {
if (typeof id === 'number' && e.id === id) {
edge = e;
break;
}
}

if (!edge) {
return from([null]);
}
return from([edge]);
}
}
81 changes: 81 additions & 0 deletions frontend/cypress/fixtures/entityDetails/entityDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export const nodeId1 = {
id: 1,
types: ['Customer'],
properties: {
country: 'UK',
contactTitle: 'Sales Representative',
address: 'Fauntleroy Circus',
city: 'London',
},
entityType: 'node',
};

export const dummyNodes = [
{
id: 1,
types: ['Customer'],
properties: {
country: 'UK',
contactTitle: 'Sales Representative',
address: 'Fauntleroy Circus',
city: 'London',
},
entityType: 'node',
},
{
id: 2,
types: ['Product'],
properties: {
reorderLevel: 5,
unitsInStock: 101,
unitPrice: 15,
supplierID: '17',
productID: '73',
},
entityType: 'node',
},
{
id: 3,
types: ['Order'],
properties: {
shipCity: 'Boise',
orderID: '10847',
freight: '487.57',
requiredDate: '1998-02-05 00:00:00.000',
employeeID: '4',
},
entityType: 'node',
},
{
id: 4,
types: ['Supplier'],
properties: {
country: 'Germany',
contactTitle: 'Coordinator Foreign Markets',
address: 'Frahmredder 112a',
supplierID: '13',
city: 'Cuxhaven',
},
entityType: 'node',
},
];

export const edgeId1 = {
id: 1,
type: 'supplies',
from: 1,
to: 0,
properties: {},
entityType: 'edge',
};

export const dummyEdges = [
{
id: 1,
type: 'supplies',
from: 1,
to: 0,
properties: {},
entityType: 'edge',
},
];
13 changes: 13 additions & 0 deletions frontend/cypress/integration/visualization/chord.e2e.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { iif } from 'rxjs';

context('Chord Diagram', () => {
// Global setup
beforeEach(() => {
Expand Down Expand Up @@ -43,4 +45,15 @@ context('Chord Diagram', () => {
.next() // next sibling
.contains('Person'); // check if it contains 'Movie'
});

it('displays an error when trying to highlight an edge', () => {
cy.get('.SearchBar').type('directed');

// click search result
cy.get('.SubList').contains('DIRECTED').click();

cy.get('#notistack-snackbar').contains(
'Only nodes and node types can be highlighted.'
);
});
});
42 changes: 42 additions & 0 deletions frontend/cypress/integration/visualization/graphDetails.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
context('Graph details', () => {
// Global setup
beforeEach(() => {
cy.visit('http://localhost:3000/visualization/graph');
});

it('shows details on node search', () => {
cy.get('.SearchBar').type('keanu');

// click search result to open details view
cy.get('.SubList').contains('Person').click();

cy.contains('Details') // find details text
.parents('.MuiCard-root') // select corresponding card
.contains('Type(s)') // find header "Type(s)"
.next() // next sibling
.contains('Person'); // check if it contains 'Movie'
});

it('shows details on edge search', () => {
cy.get('.SearchBar').type('directed');

// click search result to open details view
cy.get('.SubList').contains('DIRECTED').click();

cy.contains('Details') // find details text
.parents('.MuiCard-root') // select corresponding card
.contains('Type(s)') // find header "Type(s)"
.next() // next sibling
.contains('DIRECTED'); // check if it contains 'DIRECTED'
});

it('closes on clicking the close button', () => {
cy.get('.SearchBar').type('keanu');

// click search result to open details view
cy.get('.SubList').contains('Person').click();

cy.get('.MuiCardHeader-action').click(); // click close button
cy.get('Details').should('not.exist'); // check if details view closed
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import ContainerBuilder from '../../../../src/dependency-injection/ContainerBuilder';
import EntityDetailsState from '../../../../src/stores/details/EntityDetailsStateStore';

describe('EntityDetailsStateStore', () => {
let entityDetailsStateStore: EntityDetailsState;
const containerBuilder = new ContainerBuilder();
const container = containerBuilder.buildContainer();

beforeEach(() => {
entityDetailsStateStore = container.get(EntityDetailsState);
});

describe('Details state store', () => {
it('should initially be null', () => {
expect(
cy
.wrap(entityDetailsStateStore.getValue())
.its('node')
.should('eq', null)
);
});
it("should return the Node's id", () => {
entityDetailsStateStore.showNode(1);
expect(
cy.wrap(entityDetailsStateStore.getValue()).its('node').should('eq', 1)
);
});
it("Should return the edge's id", () => {
entityDetailsStateStore.showEdge(2);
expect(
cy.wrap(entityDetailsStateStore.getValue()).its('edge').should('eq', 2)
);
});
it('should be clearable', () => {
entityDetailsStateStore.showNode(1);
entityDetailsStateStore.clear();
expect(
cy
.wrap(entityDetailsStateStore.getValue())
.its('node')
.should('eq', null)
);
expect(
cy
.wrap(entityDetailsStateStore.getValue())
.its('edge')
.should('eq', null)
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
edgeId1,
nodeId1,
} from '../../../fixtures/entityDetails/entityDetails';
import EntityDetails from '../../../../src/stores/details/EntityDetailsStore';
import { createContainer } from '../../../../src/dependency-injection/DependencyInjectionContext';
import EntityDetailsState from '../../../../src/stores/details/EntityDetailsStateStore';
import { QueryService } from '../../../../src/services/query';
import QueryServiceFake from '../../../fixtures/QueryServiceFake';

describe('EntityDetailsStore', () => {
// Global setup
let entityDetails: EntityDetails;
let entityDetailsState: EntityDetailsState;
const container = createContainer();

beforeEach(() => {
container.unbind(QueryService);
container.bind(QueryService).to(QueryServiceFake);
entityDetails = container.get(EntityDetails);
entityDetailsState = container.get(EntityDetailsState);
});

describe('Details store', () => {
it('should initially return null', () => {
// Act
const actual = entityDetails.getValue();
const expected = null;

// Assert
expect(actual).to.be.eq(expected);
});
it('should return details of the node with id 1', () => {
// Arrange
entityDetailsState.setState({ node: 1, edge: null });

// Act
const actual = entityDetails.getValue();

// Assert
expect(actual).to.be.deep.eq(nodeId1);
});
it('should return details of the edge with id 1', () => {
// Arrange
entityDetailsState.setState({ node: null, edge: 1 });

// Act
const actual = entityDetails.getValue();

// Assert
expect(actual).to.be.deep.eq(edgeId1);
});
});
});
31 changes: 30 additions & 1 deletion frontend/src/@types/react-graph-vis.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare module 'react-graph-vis' {
Node,
Edge,
DataSet,
IdType,
} from 'vis-network';
import { Component } from 'react';

Expand All @@ -23,12 +24,40 @@ declare module 'react-graph-vis' {
DataSet,
} from 'vis-network';

export interface EventParameters {
nodes: IdType[];
edges: IdType[];
event: unknown;
pointer: {
DOM: { x: number; y: number };
canvas: { x: number; y: number };
};
}

export interface NodeClickItem {
nodeId: IdType;
labelId?: number;
}

export interface EdgeClickItem {
edgeId: IdType;
labelId?: number;
}

export type ClickItem = NodeClickItem | EdgeClickItem;

export interface ClickEventParameters extends EventParameters {
items: ClickItem[];
}

/**
* An object that has event name as keys and their callback as values that can be passed to the NetworkGraph component.
* For the events available, see: https://visjs.github.io/vis-network/docs/network/#Events
*/
export interface GraphEvents {
[event: NetworkEvents]: (params?: unknown) => void;
[event: NetworkEvents]: ((params: unknown) => void) | undefined;
click?: (params: ClickEventParameters) => void;
select?: (params: EventParameters) => void;
}

/**
Expand Down
Loading

0 comments on commit 30da220

Please sign in to comment.