Skip to content
Snippets Groups Projects
Commit 6119ce20 authored by Leonardo Christino's avatar Leonardo Christino
Browse files

feat(schema): add node detail popup when clicking nodes in schema panel

parent ea4a747f
No related branches found
No related tags found
1 merge request!60feat(schema): add node detail popup when clicking nodes in schema panel
Showing
with 326 additions and 2426 deletions
......@@ -11,3 +11,8 @@
docker: login
@docker build -t harbor.graphpolaris.com/graphpolaris/frontend:latest .
@docker push harbor.graphpolaris.com/graphpolaris/frontend\:latest
push:
@pnpm lint
@pnpm test
@pnpm build
export const Popup = (props: { children: React.ReactNode; open: boolean; hAnchor: 'left' | 'right' }) => {
import { useRef } from 'react';
export const Popup = (props: {
children: React.ReactNode;
open: boolean;
hAnchor: 'left' | 'right';
className?: string;
offset?: string;
}) => {
const ref = useRef<HTMLDivElement>(null);
return (
<>
{props.open && (
<div
className="absolute z-10 max-w-[20rem] bg-white flex flex-col gap-2 animate-openmenu p-0 m-0"
style={props.hAnchor === 'left' ? { left: '5rem' } : { right: '5rem' }}
ref={ref}
className={
'absolute z-50 max-w-[20rem] bg-white flex flex-col gap-2 animate-openmenu p-0 m-0 ' + (props.className ? props.className : '')
}
style={props.hAnchor === 'right' ? { left: props.offset || 0 } : { right: props.offset || 0 }}
>
{props.children}
</div>
......
import React, { PropsWithChildren, useEffect, useLayoutEffect, useRef, useState } from 'react';
import CloseIcon from '@mui/icons-material/Close';
export const FormDiv = React.forwardRef<HTMLDivElement, PropsWithChildren<{ className?: string; hAnchor?: string; offset?: string }>>(
(props, ref) => {
return (
<div
className={'absolute opacity-100 transition-opacity group-hover:opacity-100 z-50 ' + (props.className ? props.className : '')}
ref={ref}
style={props.hAnchor === 'left' ? { left: props.offset || 0 } : { right: props.offset || '5rem' }}
>
{props.children}
</div>
);
}
);
export const FormCard = (props: PropsWithChildren<{ className?: string }>) => (
<div className={'card card-bordered bg-white rounded-none ' + (props.className ? props.className : '')}>{props.children}</div>
);
export const FormBody = ({
children,
...props
}: PropsWithChildren<React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>>) => (
<form className="card-body px-0 w-72 py-5" {...props}>
{children}
</form>
);
export const FormTitle = ({ children, title, onClose }: PropsWithChildren<{ title: string; onClose: () => void }>) => (
<div className="card-title p-5 py-0 flex w-full">
<h2 className="w-full">{title}</h2>
<button className="btn btn-circle btn-sm btn-ghost" onClick={() => onClose()}>
<CloseIcon fontSize="small" />
</button>
</div>
);
export const FormHBar = () => <div className="divider m-0"></div>;
export const FormControl = ({ children }: PropsWithChildren) => <div className="form-control px-5">{children}</div>;
export const FormActions = (props: { onClose: () => void }) => (
<div className="card-actions mt-1 w-full px-5 flex flex-row">
<button
className="btn btn-secondary flex-grow"
onClick={(e) => {
e.preventDefault();
props.onClose();
}}
>
Cancel
</button>
<button className="btn btn-primary flex-grow">Apply</button>
</div>
);
......@@ -5,6 +5,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { useAppDispatch, useQuerybuilderSettings } from '../../data-access';
import { QueryBuilderSettings, setQuerybuilderSettings } from '../../data-access/store/querybuilderSlice';
import { addWarning } from '../../data-access/store/configSlice';
import { FormBody, FormCard, FormDiv, FormHBar, FormTitle } from '../../components/forms';
export const QuerySettingsDialog = React.forwardRef<HTMLDivElement, DialogProps>((props, ref) => {
const qb = useQuerybuilderSettings();
......@@ -31,22 +32,16 @@ export const QuerySettingsDialog = React.forwardRef<HTMLDivElement, DialogProps>
return (
<>
{props.open && (
<div className="absolute right-20 bottom-5 opacity-100 transition-opacity group-hover:opacity-100 z-50 " ref={ref}>
<div className="card card-bordered bg-white rounded-none">
<form
className="card-body px-0 w-72 py-5"
<FormDiv ref={ref} className="" hAnchor="right">
<FormCard>
<FormBody
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="card-title p-5 py-0 flex w-full">
<h2 className="w-full">Query Settings</h2>
<button className="btn btn-circle btn-sm btn-ghost" onClick={() => props.onClose()}>
<CloseIcon fontSize="small" />
</button>
</div>
<div className="divider m-0"></div>
<FormTitle title="Query Settings" onClose={props.onClose} />
<FormHBar />
<div className="form-control px-5">
<label className="label">
<span className="label-text">Limit - Max number of results</span>
......@@ -59,7 +54,7 @@ export const QuerySettingsDialog = React.forwardRef<HTMLDivElement, DialogProps>
onChange={(e) => setState({ ...state, limit: parseInt(e.target.value) })}
/>
</div>
<div className="divider m-0"></div>
<FormHBar />
<div className="form-control px-5 flex flex-row gap-3">
<div className="">
<label className="label">
......@@ -100,7 +95,7 @@ export const QuerySettingsDialog = React.forwardRef<HTMLDivElement, DialogProps>
/>
</div>
</div>
<div className="divider m-0"></div>
<FormHBar />
<div className="card-actions mt-1 w-full px-5 flex flex-row">
<button
className="btn btn-secondary flex-grow"
......@@ -113,9 +108,9 @@ export const QuerySettingsDialog = React.forwardRef<HTMLDivElement, DialogProps>
</button>
<button className="btn btn-primary flex-grow">Apply</button>
</div>
</form>
</div>
</div>
</FormBody>
</FormCard>
</FormDiv>
)}
</>
);
......
......@@ -35,9 +35,10 @@ export interface SchemaReactflowData {
nodeCount: number;
summedNullAmount: number;
label: string;
type: string;
}
export interface SchemaReactflowNode extends SchemaReactflowData {
export interface SchemaReactflowEntity extends SchemaReactflowData {
// handles: string[];
connectedRatio: number;
name: string;
......@@ -51,7 +52,7 @@ export interface SchemaReactflowRelation extends SchemaReactflowData {
toRatio: number;
}
export interface SchemaReactflowNodeWithFunctions extends SchemaReactflowNode {
export interface SchemaReactflowNodeWithFunctions extends SchemaReactflowEntity {
toggleNodeQualityPopup: (id: string) => void;
toggleAttributeAnalyticsPopupMenu: (id: string) => void;
}
......
......@@ -102,8 +102,8 @@ export const Schema = (props: Props) => {
if (schemaGraphology == undefined || schemaGraphology.order == 0) {
return;
}
// console.log(schemaGraphology.export());
// console.log(schemaLayout);
console.log(schemaGraph);
updateLayout();
const expandedSchema = schemaExpandRelation(schemaGraphology);
......
......@@ -4,6 +4,7 @@ import React from 'react';
import CloseIcon from '@mui/icons-material/Close';
import { useAppDispatch, useSchemaSettings } from '../../data-access';
import { SchemaSettings, setSchemaSettings } from '../../data-access/store/schemaSlice';
import { FormActions, FormBody, FormCard, FormControl, FormHBar, FormTitle, FormDiv } from '../../components/forms';
export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props, ref) => {
const settings = useSchemaSettings();
......@@ -23,23 +24,17 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props
return (
<>
{props.open && (
<div className="absolute opacity-100 transition-opacity group-hover:opacity-100 z-50 " ref={ref}>
<div className="card absolute card-bordered bg-white rounded-none">
<form
className="card-body px-0 w-72 py-5"
<FormDiv ref={ref}>
<FormCard>
<FormBody
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<div className="card-title p-5 py-0 flex w-full">
<h2 className="w-full">Quick Settings</h2>
<button className="btn btn-circle btn-sm btn-ghost" onClick={() => props.onClose()}>
<CloseIcon fontSize="small" />
</button>
</div>
<div className="divider m-0"></div>
<div className="form-control px-5">
<FormTitle title="Quick Settings" onClose={props.onClose} />
<FormHBar />
<FormControl>
<label className="label cursor-pointer w-fit gap-2 px-0 py-1">
<input type="checkbox" checked={true} onChange={(e) => {}} className="checkbox checkbox-xs" />
<span className="label-text">Points</span>
......@@ -52,23 +47,23 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props
<input type="checkbox" checked={true} onChange={(e) => {}} className="checkbox checkbox-xs" />
<span className="label-text">Line</span>
</label>
</div>
<div className="divider m-0"></div>
<div className="form-control px-5">
</FormControl>
<FormHBar />
<FormControl>
<label className="label">
<span className="label-text">Opacity</span>
</label>
<input type="range" min={0} max="100" value="40" onChange={(e) => {}} className="range range-sm" />
</div>
<div className="divider m-0"></div>
<div className="form-control px-5">
</FormControl>
<FormHBar />
<FormControl>
<label className="label">
<span className="label-text">Histogram</span>
</label>
...
</div>
<div className="divider m-0"></div>
<div className="form-control px-5">
</FormControl>
<FormHBar />
<FormControl>
<label className="label">
<span className="label-text">Type of Connection</span>
</label>
......@@ -92,24 +87,13 @@ export const SchemaDialog = React.forwardRef<HTMLDivElement, DialogProps>((props
Bezier
</option>
</select>
</div>
<div className="divider m-0"></div>
</FormControl>
<FormHBar />
<div className="card-actions mt-1 w-full px-5 flex flex-row">
<button
className="btn btn-secondary flex-grow"
onClick={(e) => {
e.preventDefault();
props.onClose();
}}
>
Cancel
</button>
<button className="btn btn-primary flex-grow">Apply</button>
</div>
</form>
</div>
</div>
<FormActions onClose={props.onClose} />
</FormBody>
</FormCard>
</FormDiv>
)}
</>
);
......
/**
* This program has been developed by students from the bachelor Computer Science at
* Utrecht University within the Software Project course.
* © Copyright Utrecht University (Department of Information and Computing Sciences)
*/
import SchemaViewModel from './SchemaViewModel';
import { Node, ReactFlowInstance } from 'reactflow';
import {
doBoxesOverlap,
makeBoundingBox,
calcWidthRelationNodeBox,
calcWidthEntityNodeBox,
numberPredicates,
} from '../../schema-utils/utils';
import {
AttributeAnalyticsData,
AttributeAnalyticsPopupMenuNode,
AttributeCategory,
AttributeWithData,
NodeQualityDataForEntities,
NodeQualityDataForRelations,
NodeQualityPopupNode,
NodeType,
} from '../../schema-utils/Types';
import React from 'react';
import '../../view/graph-schema/flow-components/nodes/popupmenus/NodeQualityPopupNode.scss';
import '../../view/graph-schema/flow-components/nodes/popupmenus/AttributeAnalyticsPopupMenuNode.scss';
// import {
// exportComponentAsPNG,
// Params,
// PDFOptions,
// } from 'react-component-export-image';
import { getTextOfJSDocComment } from 'typescript';
/** This class is responsible for updating and creating the graph schema. */
export default class SchemaViewModelImpl
extends AbstractBaseViewModelImpl
implements SchemaViewModel
{
private reactFlowInstance: any;
private relationCounter: number;
private drawOrderUseCase: DrawOrderUseCase;
private nodeUseCase: NodeUseCase;
private edgeUseCase: EdgeUseCase;
private graphUseCase: GraphUseCase;
private placeInQueryBuilder: (name: string, type: string) => void;
public elements: SchemaElements = { nodes: [], edges: [], selfEdges: [] };
public zoom: number;
public visible: boolean;
public nodeQualityPopup: NodeQualityPopupNode;
public attributeAnalyticsPopupMenu: AttributeAnalyticsPopupMenuNode;
public nodeQualityData: Record<
string,
NodeQualityDataForEntities | NodeQualityDataForRelations
>;
public attributeAnalyticsData: Record<string, AttributeAnalyticsData>;
private entityPopupOffsets = {
nodeQualityOffset: {
x: SchemaThemeHolder.entity.width,
y: SchemaThemeHolder.entity.height - 3,
},
attributeQualityOffset: {
x: SchemaThemeHolder.entity.width,
y: -SchemaThemeHolder.entity.height + 12,
},
};
private relationPopupOffsets = {
nodeQualityOffset: {
x: SchemaThemeHolder.relation.width + 50,
y: SchemaThemeHolder.relation.height + 2,
},
attributeQualityOffset: {
x: SchemaThemeHolder.relation.width + 50,
y: -SchemaThemeHolder.relation.height + 17,
},
};
// React flow reference for positioning on drop.
public myRef: React.RefObject<HTMLDivElement>;
public nodeTypes = {
entity: EntityNode,
relation: RelationNode,
nodeQualityEntityPopup: NodeQualityEntityPopupNode,
nodeQualityRelationPopup: NodeQualityRelationPopupNode,
attributeAnalyticsPopupMenu: AttributeAnalyticsPopupMenu,
};
public edgeTypes = {
nodeEdge: NodeEdge,
selfEdge: SelfEdge,
};
public constructor(
drawOrderUseCase: DrawOrderUseCase,
nodeUseCase: NodeUseCase,
edgeUseCase: EdgeUseCase,
graphUseCase: GraphUseCase,
addAttribute: (name: string, type: string) => void
) {
super();
this.myRef = React.createRef();
// TODO: These values need to not be hardcoded.
this.zoom = 1.3;
this.relationCounter = 0;
this.reactFlowInstance = 0;
this.visible = true;
this.edgeUseCase = edgeUseCase;
this.nodeUseCase = nodeUseCase;
this.drawOrderUseCase = drawOrderUseCase;
this.graphUseCase = graphUseCase;
this.placeInQueryBuilder = addAttribute;
this.nodeQualityPopup = this.emptyNodeQualityPopupNode();
this.attributeAnalyticsPopupMenu =
this.emptyAttributeAnalyticsPopupMenuNode();
this.nodeQualityData = {};
this.attributeAnalyticsData = {};
}
/**
* Containts all function calls to create the graph schema.
* Notifies the view about the changes at the end.
*/
public createSchema = (elements: SchemaElements): SchemaElements => {
let drawOrder: Node[];
drawOrder = this.drawOrderUseCase.createDrawOrder(elements);
// Create nodes with start position.
elements.nodes = this.nodeUseCase.setEntityNodePosition(
drawOrder,
{
x: 0,
y: 0,
},
this.toggleNodeQualityPopup,
this.toggleAttributeAnalyticsPopupMenu
);
// Create the relation-nodes.
elements.edges.forEach((relation) => {
this.createRelationNode(
relation.id,
relation.data.attributes,
relation.data.collection,
elements
);
});
elements.selfEdges.forEach((relation) => {
this.createRelationNode(
relation.id,
relation.data.attributes,
relation.data.collection,
elements
);
});
// Complement the relation-nodes with extra data that is now accessible.
elements.edges = this.edgeUseCase.positionEdges(
drawOrder,
elements,
this.setRelationNodePosition
);
this.visible = false;
this.notifyViewAboutChanges();
return elements;
};
/**
* consumes the schema send from the backend and uses it to create the SchemaElements for the schema
* @param jsonObject
*/
public consumeMessageFromBackend(jsonObject: unknown): void {
if (isSchemaResult(jsonObject)) {
// This is always the first message to receive, so reset the global variables.
this.visible = false;
this.createSchema({ nodes: [], edges: [], selfEdges: [] });
this.relationCounter = 0;
this.nodeQualityPopup.isHidden = true;
this.attributeAnalyticsPopupMenu.isHidden = true;
/* Create the graph-schema and add the popup-menu's for as far as possible.
* Runs underlying useCase trice to fix a bug with lingering selfEdges.
TODO: clean this up.*/
this.elements = this.createSchema({
nodes: [],
edges: [],
selfEdges: [],
});
let schemaElements =
this.graphUseCase.createGraphFromInputData(jsonObject);
this.elements = this.createSchema(schemaElements);
this.notifyViewAboutChanges();
//End weird fix
this.visible = true;
schemaElements = this.graphUseCase.createGraphFromInputData(jsonObject);
this.elements = this.createSchema(schemaElements);
this.notifyViewAboutChanges();
this.addAttributesToAnalyticsPopupMenus(jsonObject);
} else if (isAttributeDataEntity(jsonObject)) {
// Add all information from the received message to the popup-menu's.
this.addAttributeDataToPopupMenusAndElements(
jsonObject,
'gsa_node_result',
this.elements
);
} else if (isAttributeDataRelation(jsonObject)) {
// Add all information from the received message to the popup-menu's.
this.addAttributeDataToPopupMenusAndElements(
jsonObject,
'gsa_edge_result',
this.elements
);
} else {
// TODO: This should be an error screen eventually.
console.log('This is no valid input!');
}
this.notifyViewAboutChanges();
}
/**
* Create a reference to the reactflow schema component on load
* @param reactFlowInstance reactflow instance
*/
public onInit = (reactFlowInstance: ReactFlowInstance<any>): void => {
this.reactFlowInstance = reactFlowInstance;
};
/**
* Complements the relation-nodes with data that had default values before.
* It determines the position of the relation-node and the connected entity-nodes.
* @param centerX Used to determine the center of the edge.
* @param centerY Used to determine the center of the edge.
* @param id The id of the relation node.
* @param from The id of the entity where the edge should connect from.
* @param to The id of the entity where the edge should connect to.
* @param attributes The attributes of the relation node.
*/
public setRelationNodePosition = (
centerX: number,
centerY: number,
id: string,
from: string,
to: string,
attributes: Attribute[]
): void => {
let width: number;
let overlap: boolean;
let y: number;
// Check if the relation-node is in the list of nodes.
let relation = this.elements.nodes.find((node) => node.id == id);
if (relation == undefined)
throw new Error('Relation ' + id + ' does not exist.');
// Height of relation/entity + external buttons.
const height = 20;
width =
SchemaThemeHolder.relation.width +
calcWidthRelationNodeBox(attributes.length, 0);
let x = centerX - SchemaThemeHolder.relation.width / 2;
y = centerY - height / 2;
while (this.CheckForOverlap(x, y, width, height)) {
y = y + 1;
}
// Replace the default values for the correct values.
relation.position = { x: x, y: y };
relation.data.from = from;
relation.data.to = to;
this.relationCounter++;
if (this.relationCounter == this.elements.edges.length) {
this.fitToView();
}
this.notifyViewAboutChanges();
};
/**
* Creates a new relation-node with some default values.
* @param id The id of the relation node.
* @param attributes The list of attributes that this relation-node has.
* @param collection The collection this relation-node is in.
*/
public createRelationNode = (
id: string,
attributes: Attribute[],
collection: string,
schemaElements: SchemaElements
): void => {
// Height of relation/entity + external buttons.
const height = 20;
const width =
SchemaThemeHolder.relation.width +
calcWidthRelationNodeBox(attributes.length, 0);
schemaElements.nodes.push({
type: NodeType.relation,
id: id,
position: { x: 0, y: 0 },
data: {
width: width,
height: height,
collection: collection,
attributes: attributes,
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
toggleNodeQualityPopup: this.toggleNodeQualityPopup,
toggleAttributeAnalyticsPopupMenu:
this.toggleAttributeAnalyticsPopupMenu,
},
});
};
/**
* Calculates the width and height of the graph-schema-panel.
* @returns { number; number } The width and the height of the graph-schema-panel.
*/
public getWidthHeight = (): { width: number; height: number } => {
const reactFlow = this.myRef.current as HTMLDivElement;
const reactFlowBounds = reactFlow.getBoundingClientRect();
const width = reactFlowBounds.right - reactFlowBounds.left;
const height = reactFlowBounds.bottom - reactFlowBounds.top;
return { width, height };
};
/** Placeholder function for fitting the schema into the view. */
public fitToView = (): void => {
let minX = Infinity;
let maxX = 0;
let minY = Infinity;
let maxY = 0;
let xZoom = 0;
let yZoom = 0;
let setY = 0;
let setX = 0;
let setZoom = 0;
this.elements.nodes.forEach((node) => {
const attributeCount: number = node.data.attributes.length;
const nodeCount: number = node.data.nodeCount;
const nodeWidth: number =
node.type == NodeType.entity
? SchemaThemeHolder.entity.width +
calcWidthEntityNodeBox(attributeCount, nodeCount)
: SchemaThemeHolder.relation.width +
calcWidthRelationNodeBox(attributeCount, nodeCount);
if (node.position.x < minX) minX = node.position.x;
if (node.position.x + nodeWidth > maxX)
maxX = node.position.x + nodeWidth;
if (node.position.y < minY) minY = node.position.y;
if (node.position.y + node.data.height > maxY)
maxY = node.position.y + node.data.height;
});
minX -= 10;
maxX += 90;
const { width, height } = this.getWidthHeight();
// Correct for X and Y position with width and height.
let nodeWidth = Math.abs(maxX - minX);
let nodeHeight = Math.abs(maxY - minY);
setX = minX * -1;
xZoom = width / nodeWidth;
setY = minY * -1;
yZoom = height / nodeHeight;
// TODO: Correct position and zoom for selfEdges.
if (xZoom >= yZoom) {
setZoom = yZoom;
setX = setX + width / 2 - nodeWidth / 2;
} else {
setZoom = xZoom;
setY = setY + height / 2 - nodeHeight / 2;
}
try {
this.reactFlowInstance.setTransform({ x: setX, y: setY, zoom: setZoom });
} catch {
console.log('this.reactFlowInstance is undefined!');
}
};
/**
* this function check for a relation node if it overlaps with any of the other nodes in the schema.
* It creates boundingbox for the node and checks with all the other nodes if the boxes overlap.
* @param x Top left x of the node.
* @param y Top left y of the node.
* @param width Width of the node.
* @param height Height of the node.
* @returns {bool} whether it overlaps.*/
public CheckForOverlap = (
x: number,
y: number,
width: number,
height: number
): boolean => {
const boundingBox = makeBoundingBox(x, y, width, height);
let elements = this.elements;
let boundingTemporary: BoundingBox;
for (let i = 0; i < elements.nodes.length; i++) {
boundingTemporary = makeBoundingBox(
elements.nodes[i].position.x,
elements.nodes[i].position.y,
elements.nodes[i].data.width,
elements.nodes[i].data.height
);
if (doBoxesOverlap(boundingBox, boundingTemporary)) {
return true;
}
}
return false;
};
/** Exports the schema builder to a beautiful png file */
public exportToPNG(): void {
const { width, height } = this.getWidthHeight();
exportComponentAsPNG(this.myRef, {
fileName: 'schemaBuilder',
pdfOptions: {
x: 0,
y: 0,
w: width,
h: height,
unit: 'px',
} as Partial<PDFOptions>,
} as Params);
}
/** Not implemented method for exporting the schema builder visualisation to PDF. */
public exportToPDF(): void {
console.log('Method not implemented.');
}
/** Attach the listener to the broker. */
public subscribeToSchemaResult(): void {
Broker.instance().subscribe(this, 'schema_result');
}
/** Detach the listener to the broker. */
public unSubscribeFromSchemaResult(): void {
Broker.instance().unSubscribe(this, 'schema_result');
}
/** Attach the listeners to the broker. */
public subscribeToAnalyticsData(): void {
Broker.instance().subscribe(this, 'gsa_node_result');
Broker.instance().subscribe(this, 'gsa_edge_result');
}
/** Detach the listeners to the broker. */
public unSubscribeFromAnalyticsData(): void {
Broker.instance().unSubscribe(this, 'gsa_node_result');
Broker.instance().unSubscribe(this, 'gsa_edge_result');
}
/**
* This function is used by relation and entity nodes to hide or show the node-quality popup of that node.
* @param id of the node for which the new popup is.
*/
public toggleNodeQualityPopup = (id: string): void => {
const popup = this.nodeQualityPopup;
// Hide the popup if the current popup is visible and if the popup belongs to the same node as the given id.
if (popup.nodeID == id && !popup.isHidden) popup.isHidden = true;
// Else make and show a new popup for the node with the given id.
else this.updateNodeQualityPopup(id, this.elements);
this.notifyViewAboutChanges();
};
/**
* This function shows and updates the node-quality popup for the node which has the given id.
* @param id of the node for which the new popup is.
*/
private updateNodeQualityPopup(id: string, schemaElements: SchemaElements) {
let node = schemaElements.nodes.find((node) => node.id == id);
if (node == undefined) {
throw new Error('Node does not exist therefore no popup can be shown.');
}
const popup = this.nodeQualityPopup;
popup.nodeID = id;
popup.isHidden = false;
popup.data = this.nodeQualityData[id];
if (node.type == 'entity') {
// Make changes to the popup, to make it a popup for entities.
this.updateToNodeQualityEntityPopup(node);
} else {
// Make changes to the popup, to make it a popup for relations.
this.updateToNodeQualityRelationPopup(node);
}
// Hide the attributeAnalyticsPopupMenu so that only one popup is displayed.
this.attributeAnalyticsPopupMenu.isHidden = true;
this.notifyViewAboutChanges();
this.relationCounter++;
if (this.relationCounter == schemaElements.edges.length) {
this.fitToView();
}
}
/**
* This displays the new node-quality popup for the given entity.
* @param node This is the entity of which you want to display the popup.
*/
private updateToNodeQualityEntityPopup(node: Node) {
const popup = this.nodeQualityPopup;
const offset = this.entityPopupOffsets.nodeQualityOffset;
popup.position = {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
};
popup.type = 'nodeQualityEntityPopup';
}
/**
* This displays the new node-quality popup for the given relation.
* @param node This is the relation of which you want to display the popup.
*/
private updateToNodeQualityRelationPopup(node: Node) {
const popup = this.nodeQualityPopup;
const offset = this.relationPopupOffsets.nodeQualityOffset;
popup.position = {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
};
popup.type = 'nodeQualityRelationPopup';
}
/**
* This function is used by relation and entity nodes to hide or show the attribute analyics popup menu of that node.
* @param id of the node for which the popup is.
*/
public toggleAttributeAnalyticsPopupMenu = (id: string): void => {
const popupMenu = this.attributeAnalyticsPopupMenu;
// Hide the popup menu if the current popup menu is visible and if the popup menu belongs to the same node as the given id.
if (popupMenu.nodeID == id && !popupMenu.isHidden)
popupMenu.isHidden = true;
// Else make and show a new popup menu for the node with the given id.
else this.updateAttributeAnalyticsPopupMenu(id, this.elements);
this.notifyViewAboutChanges();
};
/**
* This displays the attribute-analytics popup menu for the given node (entity or relation).
* It removes the other menus from the screen.
* @param id This is the id of the node (entity or relation) of which you want to display the menu.
*/
public updateAttributeAnalyticsPopupMenu = (
id: string,
schemaElements: SchemaElements
): void => {
const node = schemaElements.nodes.find((node) => node.id == id);
if (node == undefined)
throw new Error(
'Node ' + id + ' does not exist therefore no popup menu can be shown.'
);
const popupMenu = this.attributeAnalyticsPopupMenu;
// Make new popup menu for the node.
popupMenu.nodeID = id;
popupMenu.isHidden = false;
popupMenu.data = { ...this.attributeAnalyticsData[id] };
if (node.type == NodeType.entity) {
const offset = this.entityPopupOffsets.attributeQualityOffset;
popupMenu.position = {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
};
} else {
const offset = this.relationPopupOffsets.attributeQualityOffset;
popupMenu.position = {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
};
}
// Hide the nodeQualityPopup so that only one popup is displayed.
this.nodeQualityPopup.isHidden = true;
this.notifyViewAboutChanges();
};
/** This removes the node quality popup from the screen. */
public hideNodeQualityPopup = (): void => {
this.nodeQualityPopup.isHidden = true;
this.notifyViewAboutChanges();
};
/** This removes the attribute-analytics popup menu from the screen. */
public hideAttributeAnalyticsPopupMenu = (): void => {
this.attributeAnalyticsPopupMenu.isHidden = true;
this.notifyViewAboutChanges();
};
/**
* This sets all the data for the attributesPopupMenu without the attribute data.
* @param schemaResult This is the schema result that you get (so no attribute data yet).
*/
addAttributesToAnalyticsPopupMenus = (schemaResult: Schema): void => {
this.nodeQualityData = {};
this.attributeAnalyticsData = {};
// Firstly, loop over all entities and add the quality-data (as far as possible).
// Then add the attribute-data (as far as possible).
schemaResult.nodes.forEach((node) => {
this.nodeQualityData[node.name] = {
nodeCount: 0,
notConnectedNodeCount: 0,
attributeNullCount: 0,
isAttributeDataIn: false,
onClickCloseButton: this.hideNodeQualityPopup,
};
let attributes: any = [];
node.attributes.forEach((attribute) => {
attributes.push({
attribute: attribute,
category: AttributeCategory.undefined,
nullAmount: 0,
});
});
this.attributeAnalyticsData[node.name] = {
nodeID: node.name,
nodeType: NodeType.entity,
attributes: attributes,
isAttributeDataIn: false,
onClickCloseButton: this.hideAttributeAnalyticsPopupMenu,
onClickPlaceInQueryBuilderButton: this.placeInQueryBuilder,
searchForAttributes: this.searchForAttributes,
resetAttributeFilters: this.resetAttributeFilters,
applyAttributeFilters: this.applyAttributeFilters,
};
});
// Secondly, loop over all relations and add the quality-data (as far as possible).
// Then add the attribute-data (as far as possible).
schemaResult.edges.forEach((edge) => {
this.nodeQualityData[edge.collection] = {
nodeCount: 0,
fromRatio: 0,
toRatio: 0,
attributeNullCount: 0,
notConnectedNodeCount: 0,
isAttributeDataIn: false,
onClickCloseButton: this.hideNodeQualityPopup,
};
let attributes: any = [];
edge.attributes.forEach((attribute) => {
attributes.push({
attribute: attribute,
category: AttributeCategory.undefined,
nullAmount: 0,
});
});
this.attributeAnalyticsData[edge.collection] = {
nodeID: edge.collection,
nodeType: NodeType.relation,
attributes: attributes,
isAttributeDataIn: false,
onClickCloseButton: this.hideAttributeAnalyticsPopupMenu,
onClickPlaceInQueryBuilderButton: this.placeInQueryBuilder,
searchForAttributes: this.searchForAttributes,
resetAttributeFilters: this.resetAttributeFilters,
applyAttributeFilters: this.applyAttributeFilters,
};
});
};
/** Returns an empty quality popup node for react-flow. */
public emptyAttributeAnalyticsPopupMenuNode(): AttributeAnalyticsPopupMenuNode {
return {
id: 'attributeAnalyticsPopupMenu',
nodeID: '',
position: { x: 0, y: 0 },
data: {
nodeID: '',
nodeType: NodeType.relation,
attributes: [],
isAttributeDataIn: false,
onClickCloseButton: this.hideAttributeAnalyticsPopupMenu,
onClickPlaceInQueryBuilderButton: this.placeInQueryBuilder,
searchForAttributes: this.searchForAttributes,
resetAttributeFilters: this.resetAttributeFilters,
applyAttributeFilters: this.applyAttributeFilters,
},
type: 'attributeAnalyticsPopupMenu',
isHidden: true,
className: 'attributeAnalyticsPopupMenu',
};
}
/** Returns an empty quality popup node for react-flow. */
public emptyNodeQualityPopupNode(): NodeQualityPopupNode {
return {
id: 'nodeQualityPopup',
position: { x: 0, y: 0 },
data: {
nodeCount: 0,
notConnectedNodeCount: 0,
attributeNullCount: 0,
isAttributeDataIn: false,
onClickCloseButton: this.hideNodeQualityPopup,
},
type: 'nodeQualityEntityPopup',
isHidden: true,
nodeID: '',
className: 'nodeQualityPopup',
};
}
/**
* This function adjusts the values from the new attribute data into
* the attribute-analytics data and the node-quality data.
* @param attributeData The data that comes from the backend with (some) data about the attributes.
*/
public addAttributeDataToPopupMenusAndElements = (
attributeData: AttributeData,
attributeDataType: string,
schemaElements: SchemaElements
): void => {
// Check if attributeData is a node/entity.
if (attributeDataType === 'gsa_node_result') {
const entity = attributeData as NodeAttributeData;
// If it is a entity then add the data for the corresponding entity.
if (entity.id in this.attributeAnalyticsData) {
const attributeDataOfEntity = this.attributeAnalyticsData[entity.id];
attributeDataOfEntity.isAttributeDataIn = true;
entity.attributes.forEach((attribute) => {
// Check if attribute is in the list with attributes from the correct entity.
const attributeFound = attributeDataOfEntity.attributes.find(
(attribute_) => attribute_.attribute.name == attribute.name
);
if (attributeFound !== undefined) {
attributeFound.category = attribute.type;
attributeFound.nullAmount = attribute.nullAmount;
}
});
} // Not throw new error, because it should not crash, a message is enough and not all data will be shown.
else
console.log(
'entity ' + entity.id + ' is not in attributeAnalyticsData'
);
if (entity.id in this.nodeQualityData) {
const qualityDataOfEntity = this.nodeQualityData[
entity.id
] as NodeQualityDataForEntities;
qualityDataOfEntity.nodeCount = entity.length;
qualityDataOfEntity.attributeNullCount = entity.summedNullAmount;
qualityDataOfEntity.notConnectedNodeCount = Number(
(1 - entity.connectedRatio).toFixed(2)
);
qualityDataOfEntity.isAttributeDataIn = true;
} // Not throw new error, because it should not crash, a message is enough and not all data will be shown.
else console.log('entity ' + entity.id + ' is not in nodeQualityData');
// Check also if the entity exists in the this.elements-list.
// If so, add the new data to it.
const elementsNode = schemaElements.nodes.find(
(node_) => node_.id == entity.id
);
if (elementsNode !== undefined) {
elementsNode.data.nodeCount = entity.length;
elementsNode.data.summedNullAmount = entity.summedNullAmount;
elementsNode.data.connectedRatio = entity.connectedRatio;
}
}
// Check if attributeData is an edge/relation.
else if (attributeDataType === 'gsa_edge_result') {
const relation = attributeData as EdgeAttributeData;
// If it is a relation then add the data for the corresponding relation.
if (relation.id in this.attributeAnalyticsData) {
const attributeDataOfRelation =
this.attributeAnalyticsData[relation.id];
attributeDataOfRelation.isAttributeDataIn = true;
relation.attributes.forEach((attribute) => {
// Check if attribute is in the list with attributes from the correct relation.
const attributeFound = attributeDataOfRelation.attributes.find(
(attribute_) => attribute_.attribute.name == attribute.name
);
if (attributeFound !== undefined) {
attributeFound.category = attribute.type;
attributeFound.nullAmount = attribute.nullAmount;
}
});
} // Not throw new error, because it should not crash, a message is enough and not all data will be shown.
else
console.log(
'relation ' + relation.id + ' is not in attributeAnalyticsData'
);
if (relation.id in this.nodeQualityData) {
const qualityDataOfRelation = this.nodeQualityData[
relation.id
] as NodeQualityDataForRelations;
qualityDataOfRelation.nodeCount = relation.length;
qualityDataOfRelation.attributeNullCount = relation.summedNullAmount;
qualityDataOfRelation.fromRatio = Number(relation.fromRatio.toFixed(2));
qualityDataOfRelation.toRatio = Number(relation.toRatio.toFixed(2));
qualityDataOfRelation.isAttributeDataIn = true;
} // Not throw new error, because it should not crash, a message is enough and not all data will be shown.
else
console.log('relation ' + relation.id + ' is not in nodeQualityData');
// Check also if the entity exists in the this.elements-list.
// If so, add the new data to it.
const elementsNode = schemaElements.nodes.find(
(node_) => node_.id == relation.id
);
if (elementsNode !== undefined) {
elementsNode.data.nodeCount = relation.length;
elementsNode.data.summedNullAmount = relation.summedNullAmount;
elementsNode.data.fromRatio = relation.fromRatio;
elementsNode.data.toRatio = relation.toRatio;
}
} else throw new Error('This data is not valid!');
};
/**
* Filter out attributes that do not contain the given searchbar-value.
* @param id The id of the node the attributes are from.
* @param searchbarValue The value of the searchbar.
*/
public searchForAttributes = (id: string, searchbarValue: string): void => {
const data = this.attributeAnalyticsData[id];
// Check if there is data available.
if (data !== undefined) {
let passedAttributes: AttributeWithData[] = [];
data.attributes.forEach((attribute) => {
if (
attribute.attribute.name
.toLowerCase()
.includes(searchbarValue.toLowerCase())
)
passedAttributes.push(attribute);
});
this.attributeAnalyticsPopupMenu.data.attributes = passedAttributes;
this.notifyViewAboutChanges();
}
};
/**
* Reset the current used filters for the attribute-list.
* @param id The id of the node the attributes are from.
*/
public resetAttributeFilters = (id: string): void => {
const data = this.attributeAnalyticsData[id];
// Check if there is data available.
if (data !== undefined) {
this.attributeAnalyticsPopupMenu.data.attributes = data.attributes;
this.notifyViewAboutChanges();
}
};
/**
* Applies the chosen filters on the list of attributes of the particular node.
* @param id The id of the node the attributes are from.
* @param dataType The given type of the data you want to filter on (numerical, categorical, other).
* @param predicate The given predicate.
* @param percentage The given percentage you want to compare the null-values on.
*/
public applyAttributeFilters = (
id: string,
dataType: AttributeCategory,
predicate: string,
percentage: number
): void => {
const data = this.attributeAnalyticsData[id];
// Check if there is data available.
if (data !== undefined) {
let passedAttributes: AttributeWithData[] = [];
data.attributes.forEach((attribute) => {
// If the value is undefined it means that this filter is not chosen, so that must not be taken into account for further filtering.
if (
attribute.category == dataType ||
dataType == AttributeCategory.undefined
)
if (predicate == '' || percentage == -1)
// If the string is empty it means that this filter is not chosen, so that must not be taken into account for filtering.
passedAttributes.push(attribute);
else if (
numberPredicates[predicate](attribute.nullAmount, percentage)
)
passedAttributes.push(attribute);
});
this.attributeAnalyticsPopupMenu.data.attributes = passedAttributes;
this.notifyViewAboutChanges();
}
};
}
/**
* This program has been developed by students from the bachelor Computer Science at
* Utrecht University within the Software Project course.
* © Copyright Utrecht University (Department of Information and Computing Sciences)
*/
import {
schema,
schema2,
} from '../../../data/mock-data/graph-schema/MockGraph';
import {
mockAttributeDataNLEdge2,
mockAttributeDataNLEdge2IncorrectId,
mockAttributeDataNLNode1,
mockAttributeDataNLNode2,
mockAttributeDataNLNode2IncorrectId,
} from '../../../data/mock-data/graph-schema/MockAttributeDataBatchedNL';
import SecondChamberSchemaMock from '../../../data/mock-data/schema-result/2ndChamberSchemaMock';
import GraphUseCase from '../../../domain/usecases/graph-schema/GraphUseCase';
import SchemaViewModelImpl from './SchemaViewModel';
import { Node, Edge, ArrowHeadType } from 'react-flow-renderer';
import DrawOrderUseCase from '../../../domain/usecases/graph-schema/DrawOrderUseCase';
import EdgeUseCase from '../../../domain/usecases/graph-schema/EdgeUseCase';
import NodeUseCase from '../../../domain/usecases/graph-schema/NodeUseCase';
import {
AttributeCategory,
BoundingBox,
NodeQualityDataForEntities,
NodeQualityDataForRelations,
NodeType,
} from '../../../domain/entity/graph-schema/structures/Types';
import {
Attribute,
AttributeData,
} from '../../../domain/entity/graph-schema/structures/InputDataTypes';
import mockQueryResult from '../../../data/mock-data/query-result/big2ndChamberQueryResult';
import mockSchemaResult from '../../../data/mock-data/schema-result/2ndChamberSchemaMock';
import Broker from '../../../domain/entity/broker/broker';
jest.mock('../../view/graph-schema/SchemaStyleSheet');
jest.mock('../../util/graph-schema/utils.tsx', () => {
return {
//TODO Is this already updated?
getWidthOfText: () => {
return 10;
},
calcWidthRelationNodeBox: () => {
return 75;
},
calcWidthEntityNodeBox: () => {
return 75;
},
makeBoundingBox: (x: number, y: number, width: number, height: number) => {
let boundingBox: BoundingBox;
boundingBox = {
topLeft: { x: x, y: y },
bottomRight: { x: x + width, y: y + height },
};
return boundingBox;
},
doBoxesOverlap: (firstBB: BoundingBox, secondBB: BoundingBox) => {
if (
firstBB.topLeft.x >= secondBB.bottomRight.x ||
secondBB.topLeft.x >= firstBB.bottomRight.x
)
return false;
if (
firstBB.topLeft.y >= secondBB.bottomRight.y ||
secondBB.topLeft.y >= firstBB.bottomRight.y
)
return false;
return true;
},
};
});
describe('schemaViewModelImpl', () => {
beforeEach(() => jest.resetModules());
const graphUseCase = new GraphUseCase();
const drawOrderUseCase = new DrawOrderUseCase();
const nodeUseCase = new NodeUseCase();
const edgeUseCase = new EdgeUseCase();
const attributesInQueryBuilder: any = [];
const addAttribute = (name: string, type: string) => {
attributesInQueryBuilder.push({ name: name, type: type });
return;
};
function anonymous(
attributes: Attribute[],
id: string,
hiddenAttributes: boolean
): void {
//Empty methode.
}
it('should create a relation', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const expectedrelationode = {
type: 'relation',
id: '5',
position: { x: -72.5, y: 20 },
data: {
width: 220,
height: 20,
collection: 'none',
attributes: [],
from: 'from:here',
to: 'to:here',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
};
schemaViewModel.createRelationNode(
'5',
[],
'none',
schemaViewModel.elements
);
schemaViewModel.setRelationNodePosition(
0,
0,
'5',
'from:here',
'to:here',
[]
);
expect(JSON.stringify(schemaViewModel.elements.nodes[0])).toEqual(
JSON.stringify(expectedrelationode)
);
const expectedrelationode2 = {
type: 'relation',
id: '6',
position: { x: -72.5, y: 40 },
data: {
width: 220,
height: 20,
collection: 'none',
attributes: [],
from: 'from:hereagain',
to: 'to:hereagain',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
};
schemaViewModel.createRelationNode(
'6',
[],
'none',
schemaViewModel.elements
);
schemaViewModel.setRelationNodePosition(
0,
0,
'6',
'from:hereagain',
'to:hereagain',
[]
);
expect(JSON.stringify(schemaViewModel.elements.nodes[1])).toEqual(
JSON.stringify(expectedrelationode2)
);
});
it('should console log that method is not implemented', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const consoleSpy = jest.spyOn(console, 'log');
schemaViewModel.exportToPDF();
expect(consoleSpy).toHaveBeenCalledWith('Method not implemented.');
});
it('fitToView', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const getWidthHeight = { width: 300, height: 700 };
const spy = jest.spyOn(schemaViewModel, 'getWidthHeight');
spy.mockReturnValue(getWidthHeight);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
schemaViewModel.fitToView();
const consoleSpy = jest.spyOn(console, 'log');
expect(consoleSpy).toHaveBeenCalledWith(
'this.reactFlowInstance is undefined!'
);
});
/**
* These are the testcases for consuming messages from the backend
*/
describe('consumeMessageFromBackend', () => {
it('should consume schema result only when subscribed to broker for schema_result', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const mockConsumeMessages = jest.fn();
const message = 'test schema result';
schemaViewModel.consumeMessageFromBackend = mockConsumeMessages;
// should consume message for schema result when subscribed
schemaViewModel.subscribeToSchemaResult();
Broker.instance().publish(message, 'schema_result');
expect(mockConsumeMessages.mock.calls[0][0]).toEqual(message);
expect(mockConsumeMessages).toBeCalledTimes(1);
// should not consume message for query_result
Broker.instance().publish(message, 'query_result');
expect(mockConsumeMessages).toBeCalledTimes(1);
// should not consume message for schema result when unsubscribed
schemaViewModel.unSubscribeFromSchemaResult();
Broker.instance().publish(message, 'schema_result');
expect(mockConsumeMessages).toBeCalledTimes(1);
});
it('should consume attribute-data only when subscribed to broker for gsa_node_result & gsa_edge_result', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const mockConsumeMessages = jest.fn();
const message = 'test analytics data';
schemaViewModel.consumeMessageFromBackend = mockConsumeMessages;
// should consume message for schema result when subscribed
schemaViewModel.subscribeToAnalyticsData();
Broker.instance().publish(message, 'gsa_node_result');
Broker.instance().publish(message, 'gsa_edge_result');
expect(mockConsumeMessages.mock.calls[0][0]).toEqual(message);
expect(mockConsumeMessages).toBeCalledTimes(2);
// should not consume message for schema result when unsubscribed
schemaViewModel.unSubscribeFromAnalyticsData();
Broker.instance().publish(message, 'schema_result');
Broker.instance().publish(message, 'gsa_node_result');
Broker.instance().publish(message, 'gsa_edge_result');
expect(mockConsumeMessages).toBeCalledTimes(2);
});
//TODO: also test the message for the analytics
it('should console log and should not change any elements when receiving unrelated messages', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const consoleSpy = jest.spyOn(console, 'log');
expect(schemaViewModel.visible).toEqual(true);
schemaViewModel.consumeMessageFromBackend(mockQueryResult);
expect(consoleSpy).toHaveBeenCalledWith('This is no valid input!');
expect(schemaViewModel.visible).toEqual(true);
expect(schemaViewModel.elements.nodes).toEqual([]);
expect(schemaViewModel.elements.edges).toEqual([]);
});
});
/**
* These are the testcases for the attribute-analytics popup menu
*/
describe('AttributeAnalyticsPopupMenu', () => {
it('should throw error that given id does not exist', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
// Nodes that are not in elements cannot have popups
const name = 'Edje';
expect(
schemaViewModel.elements.nodes.find((node) => node.id == name)
).toEqual(undefined);
expect(() =>
schemaViewModel.toggleAttributeAnalyticsPopupMenu(name)
).toThrowError(
'Node ' + name + ' does not exist therefore no popup menu can be shown.'
);
});
it('should show the attribute-analytics popup menu', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
const popup = schemaViewModel.attributeAnalyticsPopupMenu;
schemaViewModel.consumeMessageFromBackend(schema);
// test for entity node
const node = schemaViewModel.elements.nodes[0];
schemaViewModel.toggleAttributeAnalyticsPopupMenu(node.id);
expect(popup.isHidden).toEqual(false);
expect(popup.nodeID).toEqual(node.id);
// test for relation node
const edge = schemaViewModel.elements.edges[0];
schemaViewModel.setRelationNodePosition(
0,
0,
edge.id,
edge.source,
edge.target,
edge.data.attributes
);
schemaViewModel.toggleAttributeAnalyticsPopupMenu(edge.id);
expect(popup.isHidden).toEqual(false);
expect(popup.nodeID).toEqual(edge.id);
});
it('should hide the attribute-analytics popup menu as it was already open', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
schemaViewModel.attributeAnalyticsPopupMenu.nodeID = 'Thijs';
schemaViewModel.attributeAnalyticsPopupMenu.isHidden = false;
schemaViewModel.toggleAttributeAnalyticsPopupMenu('Thijs');
expect(schemaViewModel.attributeAnalyticsPopupMenu.isHidden).toEqual(
true
);
});
it('should close the attribute-analytics menu', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
schemaViewModel.attributeAnalyticsData['Thijs'].onClickCloseButton();
expect(schemaViewModel.attributeAnalyticsPopupMenu.isHidden).toEqual(
true
);
});
it('should make an empty attribute-analytics popupmenu', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.attributeAnalyticsPopupMenu =
schemaViewModel.emptyAttributeAnalyticsPopupMenuNode();
expect(schemaViewModel.attributeAnalyticsPopupMenu.id).toEqual(
'attributeAnalyticsPopupMenu'
);
expect(schemaViewModel.attributeAnalyticsPopupMenu.nodeID).toEqual('');
expect(schemaViewModel.attributeAnalyticsPopupMenu.data.nodeType).toEqual(
NodeType.relation
);
expect(
schemaViewModel.attributeAnalyticsPopupMenu.data.attributes
).toEqual([]);
expect(
schemaViewModel.attributeAnalyticsPopupMenu.data.isAttributeDataIn
).toEqual(false);
});
it('should place an attribute node in the querybuilder', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
const node = schemaViewModel.elements.nodes[0];
const attribute = node.data.attributes;
schemaViewModel.attributeAnalyticsData[
node.id
].onClickPlaceInQueryBuilderButton(attribute.name, attribute.type);
expect(attributesInQueryBuilder[0]).toEqual({
name: attribute.name,
type: attribute.type,
});
});
it('should have a working searchbar', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
schemaViewModel.searchForAttributes('kamerleden', 'aa');
let attributes =
schemaViewModel.attributeAnalyticsPopupMenu.data.attributes;
expect(attributes.length).toEqual(2);
schemaViewModel.searchForAttributes('kamerleden', '');
attributes = schemaViewModel.attributeAnalyticsPopupMenu.data.attributes;
expect(attributes.length).toEqual(6);
});
it('should have working filters', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
schemaViewModel.consumeMessageFromBackend(mockAttributeDataNLNode1);
schemaViewModel.applyAttributeFilters(
'kamerleden',
AttributeCategory.categorical,
'Bigger',
-1
);
let attributes =
schemaViewModel.attributeAnalyticsPopupMenu.data.attributes;
expect(attributes.length).toEqual(1);
schemaViewModel.resetAttributeFilters('kamerleden');
attributes = schemaViewModel.attributeAnalyticsPopupMenu.data.attributes;
expect(attributes.length).toEqual(6);
});
});
/**
* These are the testcases for the node-quality popup menu
*/
describe('nodeQualityPopup', () => {
it('should throw error that given id does not exist', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
expect(() => schemaViewModel.toggleNodeQualityPopup('Edje')).toThrowError(
'Node does not exist therefore no popup can be shown.'
);
});
it('should show the node-quality popup menu for an entity node', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(SecondChamberSchemaMock);
schemaViewModel.toggleNodeQualityPopup('commissies');
expect(schemaViewModel.nodeQualityPopup.isHidden).toEqual(false);
expect(schemaViewModel.nodeQualityPopup.type).toEqual(
'nodeQualityEntityPopup'
);
expect(schemaViewModel.nodeQualityPopup.nodeID).toEqual('commissies');
});
it('should show the node-quality popup menu for a relation node', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.createRelationNode(
'5',
[],
'5',
schemaViewModel.elements
);
schemaViewModel.toggleNodeQualityPopup('5');
expect(schemaViewModel.nodeQualityPopup.isHidden).toEqual(false);
expect(schemaViewModel.nodeQualityPopup.type).toEqual(
'nodeQualityRelationPopup'
);
expect(schemaViewModel.nodeQualityPopup.nodeID).toEqual('5');
});
it('should hide the node-quality popup menu as it was already open', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
schemaViewModel.nodeQualityPopup.nodeID = 'Thijs';
schemaViewModel.nodeQualityPopup.isHidden = false;
schemaViewModel.toggleNodeQualityPopup('Thijs');
expect(schemaViewModel.nodeQualityPopup.isHidden).toEqual(true);
});
it('should close the node-quality menu', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(schema);
schemaViewModel.nodeQualityData['Thijs'].onClickCloseButton();
expect(schemaViewModel.nodeQualityPopup.isHidden).toEqual(true);
});
it('should make an empty node-quality popupmenu', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.nodeQualityPopup =
schemaViewModel.emptyNodeQualityPopupNode();
expect(schemaViewModel.nodeQualityPopup.id).toEqual('nodeQualityPopup');
expect(schemaViewModel.nodeQualityPopup.nodeID).toEqual('');
expect(schemaViewModel.nodeQualityPopup.data.nodeCount).toEqual(0);
expect(schemaViewModel.nodeQualityPopup.data.attributeNullCount).toEqual(
0
);
expect(schemaViewModel.nodeQualityPopup.data.isAttributeDataIn).toEqual(
false
);
});
});
describe('AttributeData', () => {
it('should process the incoming data correctly for node-data', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
schemaViewModel.consumeMessageFromBackend(mockAttributeDataNLNode2);
const attributeAnalyticsData =
schemaViewModel.attributeAnalyticsData['commissies'];
const nodeQualityData = schemaViewModel.nodeQualityData[
'commissies'
] as NodeQualityDataForEntities;
expect(attributeAnalyticsData.isAttributeDataIn).toEqual(true);
expect(attributeAnalyticsData.attributes[0].category).toEqual(
AttributeCategory.other
);
expect(attributeAnalyticsData.attributes[0].nullAmount).toEqual(1);
expect(nodeQualityData.nodeCount).toEqual(38);
expect(nodeQualityData.attributeNullCount).toEqual(1);
expect(nodeQualityData.notConnectedNodeCount).toEqual(0.03);
});
it('should process the incoming data correctly for edge-data', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
schemaViewModel.consumeMessageFromBackend(mockAttributeDataNLEdge2);
const attributeAnalyticsData =
schemaViewModel.attributeAnalyticsData['lid_van'];
const nodeQualityData = schemaViewModel.nodeQualityData[
'lid_van'
] as NodeQualityDataForRelations;
expect(attributeAnalyticsData.isAttributeDataIn).toEqual(true);
expect(attributeAnalyticsData.attributes[0].category).toEqual(
AttributeCategory.categorical
);
expect(attributeAnalyticsData.attributes[0].nullAmount).toEqual(0);
expect(nodeQualityData.nodeCount).toEqual(149);
expect(nodeQualityData.attributeNullCount).toEqual(0);
expect(nodeQualityData.fromRatio).toEqual(1);
expect(nodeQualityData.toRatio).toEqual(1);
});
it('should console log when the given data has no corresponding id for entities', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
const consoleSpy = jest.spyOn(console, 'log');
schemaViewModel.consumeMessageFromBackend(
mockAttributeDataNLNode2IncorrectId
);
expect(consoleSpy).toBeCalledTimes(2);
});
it('should console log when the given data has no corresponding id for relations', () => {
const schemaViewModel = new SchemaViewModelImpl(
drawOrderUseCase,
nodeUseCase,
edgeUseCase,
graphUseCase,
addAttribute
);
schemaViewModel.consumeMessageFromBackend(mockSchemaResult);
const consoleSpy = jest.spyOn(console, 'log');
schemaViewModel.consumeMessageFromBackend(
mockAttributeDataNLEdge2IncorrectId
);
expect(consoleSpy).toBeCalledTimes(2);
});
});
/** expected results */
const expectedNodesInElements: Node[] = [
{
type: QueryElementTypes.Entity,
id: 'Airport',
position: { x: 0, y: 0 },
data: {
attributes: [
{ name: 'city', type: 'string' },
{ name: 'vip', type: 'bool' },
{ name: 'state', type: 'string' },
],
handles: [
'entityTargetBottom',
'entityTargetRight',
'entityTargetRight',
'entitySourceLeft',
'entitySourceLeft',
'entitySourceLeft',
'entityTargetRight',
],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: QueryElementTypes.Entity,
id: 'Plane',
position: { x: 0, y: 150 },
data: {
attributes: [
{ name: 'type', type: 'string' },
{ name: 'maxFuelCapacity', type: 'int' },
],
handles: ['entitySourceTop', 'entityTargetBottom', 'entityTargetRight'],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: QueryElementTypes.Entity,
id: 'Staff',
position: { x: 0, y: 300 },
data: {
attributes: [],
handles: ['entityTargetLeft', 'entitySourceTop', 'entitySourceBottom'],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: QueryElementTypes.Entity,
id: 'Airport2',
position: { x: 0, y: 450 },
data: {
attributes: [
{ name: 'city', type: 'string' },
{ name: 'vip', type: 'bool' },
{ name: 'state', type: 'string' },
],
handles: ['entitySourceRight', 'entitySourceRight', 'entityTargetTop'],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: QueryElementTypes.Entity,
id: 'Thijs',
position: { x: 0, y: 600 },
data: {
attributes: [],
handles: ['entitySourceRight', 'entityTargetLeft'],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: QueryElementTypes.Entity,
id: 'Unconnected',
position: { x: 0, y: 750 },
data: {
attributes: [],
handles: [],
width: 165,
height: 20,
nodeCount: 0,
summedNullAmount: 0,
connectedRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'arrivalTime',
type: 'int',
},
{
name: 'departureTime',
type: 'int',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'salary',
type: 'int',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'hallo',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'hallo',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'hallo',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'hallo',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'hallo',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
{
type: 'relation',
id: 'flights',
position: { x: 0, y: 0 },
data: {
width: 220,
height: 40,
collection: 'flights',
attributes: [
{
name: 'test',
type: 'string',
},
],
from: '',
to: '',
nodeCount: 0,
summedNullAmount: 0,
fromRatio: 0,
toRatio: 0,
},
},
];
const expectedEdgesInElements: Edge[] = [
{
id: 'flights',
source: 'Plane',
target: 'Airport',
type: 'nodeEdge',
label: 'Plane:Airport',
data: {
attributes: [],
d: 0,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceTop',
targetHandle: 'entityTargetBottom',
},
{
id: 'flights',
source: 'Airport2',
target: 'Airport',
type: 'nodeEdge',
label: 'Airport2:Airport',
data: {
attributes: [
{ name: 'arrivalTime', type: 'int' },
{ name: 'departureTime', type: 'int' },
],
d: 40,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceRight',
targetHandle: 'entityTargetRight',
},
{
id: 'flights',
source: 'Thijs',
target: 'Airport',
type: 'nodeEdge',
label: 'Thijs:Airport',
data: {
attributes: [{ name: 'hallo', type: 'string' }],
d: 80,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceRight',
targetHandle: 'entityTargetRight',
},
{
id: 'flights',
source: 'Airport',
target: 'Staff',
type: 'nodeEdge',
label: 'Airport:Staff',
data: {
attributes: [{ name: 'salary', type: 'int' }],
d: -40,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceLeft',
targetHandle: 'entityTargetLeft',
},
{
id: 'flights',
source: 'Airport',
target: 'Thijs',
type: 'nodeEdge',
label: 'Airport:Thijs',
data: {
attributes: [{ name: 'hallo', type: 'string' }],
d: -80,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceLeft',
targetHandle: 'entityTargetLeft',
},
{
id: 'flights',
source: 'Staff',
target: 'Plane',
type: 'nodeEdge',
label: 'Staff:Plane',
data: {
attributes: [{ name: 'hallo', type: 'string' }],
d: 0,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceTop',
targetHandle: 'entityTargetBottom',
},
{
id: 'flights',
source: 'Airport2',
target: 'Plane',
type: 'nodeEdge',
label: 'Airport2:Plane',
data: {
attributes: [{ name: 'hallo', type: 'string' }],
d: 120,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceRight',
targetHandle: 'entityTargetRight',
},
{
id: 'flights',
source: 'Staff',
target: 'Airport2',
type: 'nodeEdge',
label: 'Staff:Airport2',
data: {
attributes: [{ name: 'hallo', type: 'string' }],
d: 0,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceBottom',
targetHandle: 'entityTargetTop',
},
{
id: 'flights',
source: 'Airport',
target: 'Airport',
type: 'selfEdge',
label: 'Airport:Airport',
data: {
attributes: [{ name: 'test', type: 'string' }],
d: 58,
created: false,
collection: 'flights',
edgeCount: 0,
view: anonymous,
},
arrowHeadType: ArrowHeadType.Arrow,
sourceHandle: 'entitySourceLeft',
targetHandle: 'entityTargetRight',
},
];
const expectedAttributes: Node[] = [
{
type: 'attribute',
id: 'Airport:city',
position: { x: 0, y: 21 },
data: { name: 'city', datatype: 'string' },
isHidden: true,
},
{
type: 'attribute',
id: 'Airport:vip',
position: { x: 0, y: 41 },
data: { name: 'vip', datatype: 'bool' },
isHidden: true,
},
{
type: 'attribute',
id: 'Airport:state',
position: { x: 0, y: 61 },
data: { name: 'state', datatype: 'string' },
isHidden: true,
},
{
type: 'attribute',
id: 'Plane:type',
position: { x: 0, y: 171 },
data: { name: 'type', datatype: 'string' },
isHidden: true,
},
{
type: 'attribute',
id: 'Plane:maxFuelCapacity',
position: { x: 0, y: 191 },
data: { name: 'maxFuelCapacity', datatype: 'int' },
isHidden: true,
},
{
type: 'attribute',
id: 'Airport2:city',
position: { x: 0, y: 471 },
data: { name: 'city', datatype: 'string' },
isHidden: true,
},
{
type: 'attribute',
id: 'Airport2:vip',
position: { x: 0, y: 491 },
data: { name: 'vip', datatype: 'bool' },
isHidden: true,
},
{
type: 'attribute',
id: 'Airport2:state',
position: { x: 0, y: 511 },
data: { name: 'state', datatype: 'string' },
isHidden: true,
},
];
});
/** Result nodes. */
const nodes: Node[] = [
{
type: QueryElementTypes.Entity,
id: 'Thijs',
position: { x: 0, y: 0 },
data: { attributes: [] },
},
{
type: QueryElementTypes.Entity,
id: 'Airport',
position: { x: 0, y: 0 },
data: { attributes: [] },
},
{
type: QueryElementTypes.Entity,
id: 'Airport2',
position: { x: 0, y: 0 },
data: { attributes: [] },
},
{
type: QueryElementTypes.Entity,
id: 'Plane',
position: { x: 0, y: 0 },
data: { attributes: [] },
},
{
type: QueryElementTypes.Entity,
id: 'Staff',
position: { x: 0, y: 0 },
data: { attributes: [] },
},
];
/** Result links. */
const edges: Edge[] = [
{
id: 'Airport2:Airport',
label: 'Airport2:Airport',
type: 'nodeEdge',
source: 'Airport2',
target: 'Airport',
arrowHeadType: ArrowHeadType.Arrow,
data: {
d: '',
attributes: [],
},
},
{
id: 'Airport:Staff',
label: 'Airport:Staff',
type: 'nodeEdge',
source: 'Airport',
target: 'Staff',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Plane:Airport',
label: 'Plane:Airport',
type: 'nodeEdge',
source: 'Plane',
target: 'Airport',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Airport:Thijs',
label: 'Airport:Thijs',
type: 'nodeEdge',
source: 'Airport',
target: 'Thijs',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Thijs:Airport',
label: 'Thijs:Airport',
type: 'nodeEdge',
source: 'Thijs',
target: 'Airport',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Staff:Plane',
label: 'Staff:Plane',
type: 'nodeEdge',
source: 'Staff',
target: 'Plane',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Staff:Airport2',
label: 'Staff:Airport2',
type: 'nodeEdge',
source: 'Staff',
target: 'Airport2',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
{
id: 'Airport2:Plane',
label: 'Airport2:Plane',
type: 'nodeEdge',
source: 'Airport2',
target: 'Plane',
arrowHeadType: ArrowHeadType.Arrow,
data: { d: '', attributes: [] },
},
];
......@@ -10,53 +10,52 @@
* See testing plan for more details.*/
import React from 'react';
import { NodeProps } from 'reactflow';
import { NodeQualityDataForEntities } from '../../../model/reactflow';
import { SchemaReactflowEntity } from '@graphpolaris/shared/lib/schema/model';
export type SchemaEntityPopupProps = {
data: SchemaReactflowEntity;
onClose: () => void;
};
/**
* NodeQualityEntityPopupNode is the node that represents the popup that shows the node quality for an entity
* @param data Input data of type NodeQualityDataForEntities, which is for the popup.
*/
export const NodeQualityEntityPopupNode = ({ data }: NodeProps<NodeQualityDataForEntities>) => {
if (data == undefined) throw new Error('No node quality data is available for this node.');
if (data.isAttributeDataIn)
return (
<div>
<div className="title">
<span id="name">Nodes</span>
<span className="rightSideValue">{data.nodeCount}</span>
</div>
<div className="information">
<div>
<span>Null attributes</span>
<span className="rightSideValue">{data.attributeNullCount}</span>
</div>
<div>
<span>Not connected</span>
<span className="rightSideValue">{data.notConnectedNodeCount}</span>
</div>
</div>
<div className="closeButtonWrapper">
<button onClick={() => data.onClickCloseButton()} id="closeButton">
Close
</button>
</div>
</div>
);
else
return (
<div>
<div className="title">
<span id="name">Nodes</span>
<span className="rightSideValue">{data.nodeCount}</span>
</div>
<div className="information"></div>
<div className="closeButtonWrapper">
<button onClick={() => data.onClickCloseButton()} id="closeButton">
Close
</button>
export const SchemaEntityPopup = (props: SchemaEntityPopupProps) => {
return (
<div className="card card-bordered rounded-none text-[0.9rem] min-w-[10rem]">
<div className="card-body p-0">
<span className="px-2.5 pt-2">
<span>Nodes</span>
<span className="float-right">TBD</span>
</span>
<div className="h-[1px] w-full bg-offwhite-300"></div>
<div className="px-2.5 text-[0.8rem]">
<p>
Null Values: <span className="float-right">TBD</span>
</p>
<p>
Not connected: <span className="float-right">TBD</span>
</p>
</div>
<div className="h-[1px] w-full bg-offwhite-300"></div>
{/* <span>Attributes:</span>
<div className="text-xs">
{data.attributes.map((attribute) => {
return (
<div className="flex flex-row" key={attribute.name}>
<span>{attribute.name}</span>
</div>
);
})}
</div> */}
<button
className="btn btn-outline btn-accent border-0 btn-sm p-0 m-0 text-[0.8rem] mb-2 mx-2.5 min-h-0 h-5"
onClick={() => props.onClose()}
>
Close
</button>
</div>
);
</div>
);
};
......@@ -11,9 +11,10 @@
import React, { useState } from 'react';
import { Node, Handle, Position, NodeProps } from 'reactflow';
import styles from './entity.module.scss';
import { calcWidthEntityNodeBox, calculateAttributeQuality, calculateEntityQuality } from '@graphpolaris/shared/lib/schema/schema-utils';
import { SchemaReactflowNodeWithFunctions } from '../../../model/reactflow';
import { QueryElementTypes } from '@graphpolaris/shared/lib/querybuilder';
import { SchemaEntityPopup } from './SchemaEntityPopup';
import { Popup } from '@graphpolaris/shared/lib/components/Popup';
/**
* EntityNode is the node that represents the database entities.
......@@ -22,18 +23,13 @@ import { QueryElementTypes } from '@graphpolaris/shared/lib/querybuilder';
* @param {NodeProps} param0 The data of an entity flow element.
*/
export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNodeWithFunctions>) => {
// console.log(data);
const [hidden, setHidden] = useState<boolean>(true);
const [openPopup, setOpenPopup] = useState(false);
/**
* adds drag functionality in order to be able to drag the entityNode to the schema
* @param event React Mouse drag event
*/
const onDragStart = (event: React.DragEvent<HTMLDivElement>) => {
console.log('dragging entiry', id, data);
// console.log('dragging entiry', id, data);
// console.log(id, data);
event.dataTransfer.setData('application/reactflow', JSON.stringify({ type: QueryElementTypes.Entity, name: id }));
event.dataTransfer.effectAllowed = 'move';
};
......@@ -53,16 +49,25 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
};
return (
<div
className="border-l-2 bg-offwhite-200 border-l-entity-600 min-w-[8rem] text-[0.8rem]"
onDragStart={(event) => onDragStart(event)}
onDragStartCapture={(event) => onDragStart(event)}
onMouseDownCapture={(event) => {
if (!event.shiftKey) event.stopPropagation();
}}
draggable
>
{/* <div
<>
{openPopup && (
<Popup open={openPopup} hAnchor="left" className="-top-10" offset="-9rem">
<SchemaEntityPopup data={data} onClose={() => setOpenPopup(false)} />
</Popup>
)}
<div
className="border-l-2 bg-offwhite-200 border-l-entity-600 min-w-[8rem] text-[0.8rem]"
onDragStart={(event) => onDragStart(event)}
onDragStartCapture={(event) => onDragStart(event)}
onMouseDownCapture={(event) => {
if (!event.shiftKey) event.stopPropagation();
}}
onClickCapture={(event) => {
setOpenPopup(!openPopup);
}}
draggable
>
{/* <div
className={styles.entityNodeAttributesBox}
onClick={() => onClickToggleAttributeAnalyticsPopupMenu()}
style={{
......@@ -84,7 +89,7 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
</span>
<span className={styles.nodeSpan}>{data.attributes.length}</span>
</div> */}
{/* <div
{/* <div
className={styles.entityNodeNodesBox}
onClick={() => onClickToggleNodeQualityPopup()}
style={{
......@@ -106,7 +111,7 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
</span>
<span className={styles.nodeSpan}>{data.nodeCount}</span>
</div> */}
{/* <Handle
{/* <Handle
style={{ pointerEvents: 'none' }}
id="entitySourceLeft"
position={Position.Left}
......@@ -114,30 +119,30 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
type="source"
// hidden={Array.from(data.handles).includes('entitySourceLeft') ? false : true}
></Handle> */}
<Handle
style={{ pointerEvents: 'none' }}
id="entityTargetLeft"
position={Position.Left}
className={styles.handleTriangleLeft}
type="target"
// hidden={Array.from(data.handles).includes('entityTargetLeft') ? false : true}
></Handle>
<Handle
style={{ pointerEvents: 'none' }}
id="entitySourceRight"
position={Position.Right}
className={styles.handleTriangleRight}
type="source"
// hidden={Array.from(data.handles).includes('entitySourceRight') ? false : true}
></Handle>
{/* <Handle
<Handle
style={{ pointerEvents: 'none' }}
id="entityTargetLeft"
position={Position.Left}
className={styles.handleTriangleLeft}
type="target"
// hidden={Array.from(data.handles).includes('entityTargetLeft') ? false : true}
></Handle>
<Handle
style={{ pointerEvents: 'none' }}
id="entitySourceRight"
position={Position.Right}
className={styles.handleTriangleRight}
type="source"
// hidden={Array.from(data.handles).includes('entitySourceRight') ? false : true}
></Handle>
{/* <Handle
style={{ pointerEvents: 'none' }}
id="entityTargetRight"
position={Position.Right}
type="target"
// hidden={Array.from(data.handles).includes('entityTargetRight') ? false : true}
></Handle> */}
{/* <Handle
{/* <Handle
style={{ pointerEvents: 'none' }}
id="entitySourceTop"
position={Position.Top}
......@@ -151,7 +156,7 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
type="target"
// hidden={Array.from(data.handles).includes('entityTargetTop') ? false : true}
></Handle> */}
{/* <Handle
{/* <Handle
style={{ pointerEvents: 'none' }}
id="entitySourceBottom"
position={Position.Bottom}
......@@ -165,10 +170,11 @@ export const EntityNode = React.memo(({ id, data }: NodeProps<SchemaReactflowNod
type="target"
// hidden={Array.from(data.handles).includes('entityTargetBottom') ? false : true}
></Handle> */}
<div className="p-2 py-1">
<span className="">{id}</span>
<div className="p-2 py-1">
<span className="">{id}</span>
</div>
</div>
</div>
</>
);
});
......
import React from 'react';
import { Meta } from '@storybook/react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { querybuilderSlice, schemaSlice } from '@graphpolaris/shared/lib/data-access/store';
import { ReactFlowProvider } from 'reactflow';
import { NodeQualityEntityPopupNode } from './node-quality-entity-popup';
const Component: Meta<typeof NodeQualityEntityPopupNode> = {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Schema/Pills/Popups/NodeQualityEntityPopupNode',
component: NodeQualityEntityPopupNode,
decorators: [
(story) => (
<Provider store={Mockstore}>
<ReactFlowProvider>{story()}</ReactFlowProvider>
</Provider>
),
],
};
export default Component;
// A super-simple mock of a redux store
const Mockstore = configureStore({
reducer: {
querybuilder: querybuilderSlice.reducer,
// schema: schemaSlice.reducer,
},
});
export const Default = {
args: {
data: {
name: 'TestEntity',
attributes: [{ id: 'a' }],
handles: [],
nodeCount: 10,
from: 1,
to: 2,
},
},
};
/**
* This program has been developed by students from the bachelor Computer Science at
* Utrecht University within the Software Project course.
* © Copyright Utrecht University (Department of Information and Computing Sciences)
*/
/* istanbul ignore file */
/* The comment above was added so the code coverage wouldn't count this file towards code coverage.
* We do not test components/renderfunctions/styling files.
* See testing plan for more details.*/
import { FormBody, FormDiv, FormCard, FormHBar } from '@graphpolaris/shared/lib/components/forms';
import { SchemaReactflowRelation } from '@graphpolaris/shared/lib/schema/model';
import React from 'react';
import { NodeProps } from 'reactflow';
export type SchemaRelationshipPopupProps = {
data: SchemaReactflowRelation;
onClose: () => void;
};
/**
* NodeQualityEntityPopupNode is the node that represents the popup that shows the node quality for an entity
* @param data Input data of type NodeQualityDataForEntities, which is for the popup.
*/
export const SchemaRelationshipPopup = (props: SchemaRelationshipPopupProps) => {
return (
<div className="card card-bordered rounded-none text-[0.9rem] min-w-[10rem]">
<div className="card-body p-0">
<span className="px-2.5 pt-2">
<span>Relationships</span>
<span className="float-right">TBD</span>
</span>
<div className="h-[1px] w-full bg-offwhite-300"></div>
<div className="px-2.5 text-[0.8rem]">
<p>
Null Values: <span className="float-right">TBD</span>
</p>
<p>
Not connected: <span className="float-right">TBD</span>
</p>
</div>
<div className="h-[1px] w-full bg-offwhite-300"></div>
{/* <span>Attributes:</span>
<div className="text-xs">
{data.attributes.map((attribute) => {
return (
<div className="flex flex-row" key={attribute.name}>
<span>{attribute.name}</span>
</div>
);
})}
</div> */}
<button
className="btn btn-outline btn-primary border-0 btn-sm p-0 m-0 text-[0.8rem] mb-2 mx-2.5 min-h-0 h-5"
onClick={() => props.onClose()}
>
Close
</button>
</div>
</div>
);
};
......@@ -13,6 +13,8 @@ import { Node, Handle, Position, NodeProps } from 'reactflow';
import styles from './relation.module.scss';
import { SchemaReactflowRelation, SchemaReactflowRelationWithFunctions } from '../../../model/reactflow';
import { QueryElementTypes } from '@graphpolaris/shared/lib/querybuilder';
import { Popup } from '@graphpolaris/shared/lib/components/Popup';
import { SchemaRelationshipPopup } from './SchemaRelationshipPopup';
/**
* Relation node component that renders a relation node for the schema.
......@@ -20,7 +22,7 @@ import { QueryElementTypes } from '@graphpolaris/shared/lib/querybuilder';
* @param {NodeProps} param0 The data of an entity flow element.
*/
export const RelationNode = React.memo(({ id, data }: NodeProps<SchemaReactflowRelationWithFunctions>) => {
const [hidden, setHidden] = useState<boolean>(true);
const [openPopup, setOpenPopup] = useState(false);
/**
* Adds drag functionality in order to be able to drag the relationNode to the schema.
......@@ -57,24 +59,33 @@ export const RelationNode = React.memo(({ id, data }: NodeProps<SchemaReactflowR
};
return (
<div
onDragStart={(event) => onDragStart(event)}
onDragStartCapture={(event) => onDragStart(event)}
onMouseDownCapture={(event) => {
if (!event.shiftKey) event.stopPropagation();
}}
draggable
// style={{ width: 100, height: 100 }}
>
<div className="text-[0.8rem] border-l-2 bg-offwhite-200 border-l-relation-600 min-w-[8rem]">
<Handle
style={{ pointerEvents: 'none' }}
className={styles.handleTriangleTop}
id="entitySourceLeft"
position={Position.Top}
type="target"
></Handle>
{/* <div
<>
{openPopup && (
<Popup open={openPopup} hAnchor="left" className="-top-10" offset="-9rem">
<SchemaRelationshipPopup data={data} onClose={() => setOpenPopup(false)} />
</Popup>
)}
<div
onDragStart={(event) => onDragStart(event)}
onDragStartCapture={(event) => onDragStart(event)}
onMouseDownCapture={(event) => {
if (!event.shiftKey) event.stopPropagation();
}}
onClickCapture={(event) => {
setOpenPopup(!openPopup);
}}
draggable
// style={{ width: 100, height: 100 }}
>
<div className="text-[0.8rem] border-l-2 bg-offwhite-200 border-l-relation-600 min-w-[8rem]">
<Handle
style={{ pointerEvents: 'none' }}
className={styles.handleTriangleTop}
id="entitySourceLeft"
position={Position.Top}
type="target"
></Handle>
{/* <div
className={styles.relationNodeAttributesBox}
onClick={() => onClickToggleAttributeAnalyticsPopupMenu()}
style={{
......@@ -95,7 +106,7 @@ export const RelationNode = React.memo(({ id, data }: NodeProps<SchemaReactflowR
</span>
<span className={styles.nodeSpan}>{data.attributes.length}</span>
</div> */}
{/* <div
{/* <div
className={styles.relationNodeNodesBox}
onClick={() => onClickToggleNodeQualityPopup()}
style={{
......@@ -117,13 +128,19 @@ export const RelationNode = React.memo(({ id, data }: NodeProps<SchemaReactflowR
<span className={styles.nodeSpan}>{data.nodeCount}</span>
</div> */}
<div className="p-2 py-1">
<span className="">{data.collection}</span>
</div>
<div className="p-2 py-1">
<span className="">{data.collection}</span>
</div>
<Handle className={styles.handleTriangleBottom} style={{ pointerEvents: 'none' }} position={Position.Bottom} type="source"></Handle>
<Handle
className={styles.handleTriangleBottom}
style={{ pointerEvents: 'none' }}
position={Position.Bottom}
type="source"
></Handle>
</div>
</div>
</div>
</>
);
});
......
......@@ -2,7 +2,7 @@ import { SchemaReactflowNodeWithFunctions, SchemaReactflowRelation, SchemaReactf
import Graph from 'graphology';
import { Attributes } from 'graphology-types';
import { MarkerType, Edge, Node } from 'reactflow';
import { SchemaReactflowNode } from '../model/reactflow';
import { SchemaReactflowEntity } from '../model/reactflow';
import { QueryElementTypes } from '../../querybuilder';
//TODO does not belong here; maybe should go into the GraphPolarisThemeProvider
......@@ -64,9 +64,6 @@ export function schemaGraphology2Reactflow(
initialElements.nodes = createReactFlowNodes(graph);
initialElements.edges = createReactFlowEdges(graph, defaultEdgeType);
// initialElements.push(...createReactFlowRelationNodes(graph));
// initialElements.push(...createReactFlowRelationEdges(graph));
// console.log(initialElements);
return initialElements;
}
......
......@@ -11,6 +11,8 @@ export class SchemaUtils {
if (!nodes || !edges) return schemaGraphology;
nodes.forEach((node) => {
console.log(node);
const attributes: SchemaGraphologyNode = {
...node,
name: node.name,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment