mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-19 08:51:24 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { ArraysByKeyCache } from "./ArraysByKeyCache";
|
||||
|
||||
describe("Cache arrays by key", () => {
|
||||
it("should clear", () => {
|
||||
const cache = new ArraysByKeyCache<number>(3);
|
||||
const key = "key";
|
||||
cache.insert(key, 1, 0);
|
||||
cache.clear();
|
||||
expect(cache.retrieve(key, 0, 1)).toBe(null);
|
||||
});
|
||||
|
||||
it("should invalidate oldest key to keep cache size under maximum", () => {
|
||||
const cache = new ArraysByKeyCache<number>(4);
|
||||
const key1 = "key1";
|
||||
const key2 = "key2";
|
||||
cache.insert(key1, 0, 0);
|
||||
cache.insert(key2, 0, 1);
|
||||
cache.insert(key1, 1, 2);
|
||||
cache.insert(key2, 1, 3);
|
||||
|
||||
cache.insert(key1, 2, 4);
|
||||
|
||||
expect(cache.retrieve(key1, 0, 3)).toEqual([0, 2, 4]);
|
||||
expect(cache.retrieve(key2, 1, 1)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should cache and retrieve cached page within boundaries", () => {
|
||||
const cache = new ArraysByKeyCache<number>(5);
|
||||
const key = "key";
|
||||
cache.insert(key, 0, 0);
|
||||
cache.insert(key, 1, 1);
|
||||
cache.insert(key, 2, 2);
|
||||
cache.insert(key, 3, 3);
|
||||
expect(cache.retrieve(key, 0, 4)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("should not retrieve cached page outside boundaries", () => {
|
||||
const cache = new ArraysByKeyCache<number>(10);
|
||||
const key = "key";
|
||||
cache.insert(key, 0, 0);
|
||||
cache.insert(key, 1, 1);
|
||||
expect(cache.retrieve(key, 2, 1)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should not retrieve cached page overlapping boundaries", () => {
|
||||
const cache = new ArraysByKeyCache<number>(10);
|
||||
const key = "key";
|
||||
cache.insert(key, 0, 0);
|
||||
cache.insert(key, 1, 1);
|
||||
cache.insert(key, 2, 2);
|
||||
expect(cache.retrieve(key, 2, 4)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should not insert non-contiguous element", () => {
|
||||
const cache = new ArraysByKeyCache<number>(10);
|
||||
const key = "key";
|
||||
cache.insert(key, 0, 0);
|
||||
cache.insert(key, 1, 1);
|
||||
cache.insert(key, 3, 3);
|
||||
expect(cache.retrieve(key, 3, 1)).toEqual(null);
|
||||
});
|
||||
|
||||
it("should cache multiple keys", () => {
|
||||
const cache = new ArraysByKeyCache<number>(10);
|
||||
const key1 = "key1";
|
||||
cache.insert(key1, 0, 0);
|
||||
cache.insert(key1, 1, 1);
|
||||
cache.insert(key1, 2, 2);
|
||||
|
||||
const key2 = "key2";
|
||||
cache.insert(key2, 0, 3);
|
||||
cache.insert(key2, 1, 4);
|
||||
cache.insert(key2, 2, 5);
|
||||
|
||||
expect(cache.retrieve(key1, 0, 3)).toEqual([0, 1, 2]);
|
||||
expect(cache.retrieve(key2, 0, 3)).toEqual([3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Utility to cache array of objects associated to a key.
|
||||
* We use it to cache array of edge/vertex pairs (outE or inE)
|
||||
* Cache size is capped to a maximum.
|
||||
*/
|
||||
export class ArraysByKeyCache<T> {
|
||||
private cache: { [key: string]: T[] };
|
||||
private keyQueue: string[]; // Last touched key FIFO to purge cache if too big.
|
||||
private totalElements: number;
|
||||
private maxNbElements: number;
|
||||
|
||||
public constructor(maxNbElements: number) {
|
||||
this.maxNbElements = maxNbElements;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.cache = {};
|
||||
this.keyQueue = [];
|
||||
this.totalElements = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* To simplify, the array of cached elements array for a given key is dense (there is no index at which an elemnt is missing).
|
||||
* Retrieving a page within the array is guaranteed to return a complete page.
|
||||
* @param key
|
||||
* @param newElt
|
||||
* @param index
|
||||
*/
|
||||
public insert(key: string, index: number, newElt: T): void {
|
||||
const elements: T[] = this.cache[key] || [];
|
||||
this.cache[key] = elements;
|
||||
|
||||
if (index < 0) {
|
||||
console.error("Inserting with negative index is not allowed by ArraysByCache");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that previous index is populated, if not, ignore
|
||||
if (index > elements.length) {
|
||||
console.error("Inserting non-contiguous element is not allowed by ArraysByCache");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update last updated
|
||||
this.markKeyAsTouched(key);
|
||||
|
||||
if (this.totalElements + 1 > this.maxNbElements && key !== this.keyQueue[0]) {
|
||||
this.reduceCacheSize();
|
||||
}
|
||||
|
||||
elements[index] = newElt;
|
||||
this.totalElements++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a page of elements.
|
||||
* Return array of elements or null. null means "complete page not found in cache".
|
||||
* @param key
|
||||
* @param startIndex
|
||||
* @param pageSize
|
||||
*/
|
||||
public retrieve(key: string, startIndex: number, pageSize: number): T[] {
|
||||
if (!this.cache.hasOwnProperty(key)) {
|
||||
return null;
|
||||
}
|
||||
const elements = this.cache[key];
|
||||
if (startIndex + pageSize > elements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return elements.slice(startIndex, startIndex + pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate elements to keep the total number below the limit
|
||||
* TODO: instead of invalidating the entire array, remove only needed number of elements
|
||||
*/
|
||||
private reduceCacheSize(): void {
|
||||
// remove an key and its array
|
||||
const oldKey = this.keyQueue.shift();
|
||||
this.totalElements -= this.cache[oldKey].length;
|
||||
delete this.cache[oldKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubble up this key as new.
|
||||
* @param key
|
||||
*/
|
||||
private markKeyAsTouched(key: string) {
|
||||
const n = this.keyQueue.indexOf(key);
|
||||
if (n > -1) {
|
||||
this.keyQueue.splice(n, 1);
|
||||
}
|
||||
this.keyQueue.push(key);
|
||||
}
|
||||
}
|
||||
158
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts
Normal file
158
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as sinon from "sinon";
|
||||
import { D3ForceGraph, LoadMoreDataAction, D3GraphNodeData } from "./D3ForceGraph";
|
||||
import { D3Node, D3Link, GraphData } from "../GraphExplorerComponent/GraphData";
|
||||
import GraphTab from "../../Tabs/GraphTab";
|
||||
|
||||
describe("D3ForceGraph", () => {
|
||||
const v1Id = "v1";
|
||||
const l1: D3Link = {
|
||||
id: "id1",
|
||||
inV: v1Id,
|
||||
outV: "v2",
|
||||
label: "l1",
|
||||
source: null,
|
||||
target: null
|
||||
};
|
||||
|
||||
it("should count neighbors", () => {
|
||||
const l2: D3Link = {
|
||||
id: "id1",
|
||||
inV: "v2",
|
||||
outV: v1Id,
|
||||
label: "l2",
|
||||
source: null,
|
||||
target: null
|
||||
};
|
||||
|
||||
const l3: D3Link = {
|
||||
id: "id1",
|
||||
inV: v1Id,
|
||||
outV: "v3",
|
||||
label: "l3",
|
||||
source: null,
|
||||
target: null
|
||||
};
|
||||
|
||||
const links = [l1, l2, l3];
|
||||
const count = D3ForceGraph.countEdges(links);
|
||||
|
||||
expect(count.get(v1Id)).toBe(3);
|
||||
expect(count.get("v2")).toBe(2);
|
||||
expect(count.get("v3")).toBe(1);
|
||||
});
|
||||
|
||||
describe("Behavior", () => {
|
||||
let forceGraph: D3ForceGraph;
|
||||
let rootNode: SVGSVGElement;
|
||||
|
||||
const newGraph: GraphData<D3Node, D3Link> = new GraphData();
|
||||
newGraph.addVertex({
|
||||
id: v1Id,
|
||||
label: "vlabel1",
|
||||
_isRoot: true
|
||||
});
|
||||
newGraph.addVertex({
|
||||
id: "v2",
|
||||
label: "vlabel2"
|
||||
});
|
||||
newGraph.addEdge(l1);
|
||||
|
||||
beforeAll(() => {
|
||||
rootNode = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
rootNode.setAttribute("class", "maingraph");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rootNode.remove();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
forceGraph = new D3ForceGraph({
|
||||
graphConfig: GraphTab.createGraphConfig(),
|
||||
onHighlightedNode: sinon.spy(),
|
||||
onLoadMoreData: (action: LoadMoreDataAction): void => {},
|
||||
|
||||
// parent to graph
|
||||
onInitialized: sinon.spy(),
|
||||
|
||||
// For unit testing purposes
|
||||
onGraphUpdated: null
|
||||
});
|
||||
|
||||
forceGraph.init(rootNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
forceGraph.destroy();
|
||||
});
|
||||
|
||||
it("should render graph d3 nodes and edges", done => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
expect($(rootNode).find(".nodes").length).toBe(1);
|
||||
expect($(rootNode).find(".links").length).toBe(1);
|
||||
done();
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
|
||||
it("should render vertices (as circle)", done => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
expect($(rootNode).find(".node circle").length).toBe(2);
|
||||
done();
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
|
||||
it("should render vertex label", done => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
expect($(rootNode).find(`text:contains(${v1Id})`).length).toBe(1);
|
||||
done();
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
|
||||
it("should render root vertex", done => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
expect($(rootNode).find(".node.root").length).toBe(1);
|
||||
done();
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
|
||||
it("should render edge", done => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
expect($(rootNode).find("path.link").length).toBe(1);
|
||||
done();
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
|
||||
it("should call onInitialized callback", () => {
|
||||
expect((forceGraph.params.onInitialized as sinon.SinonSpy).calledOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onHighlightedNode callback when mouse hovering over node", () => {
|
||||
forceGraph.params.onGraphUpdated = () => {
|
||||
const mouseoverEvent = document.createEvent("Events");
|
||||
mouseoverEvent.initEvent("mouseover", true, false);
|
||||
$(rootNode)
|
||||
.find(".node")[0]
|
||||
.dispatchEvent(mouseoverEvent); // [0] is v1 vertex
|
||||
|
||||
// onHighlightedNode is always called once to clear the selection
|
||||
expect((forceGraph.params.onHighlightedNode as sinon.SinonSpy).calledTwice).toBe(true);
|
||||
|
||||
const onHighlightedNode = (forceGraph.params.onHighlightedNode as sinon.SinonSpy).args[1][0] as D3GraphNodeData;
|
||||
expect(onHighlightedNode).not.toBe(null);
|
||||
expect(onHighlightedNode.id).toEqual(v1Id);
|
||||
};
|
||||
|
||||
forceGraph.updateGraph(newGraph);
|
||||
});
|
||||
});
|
||||
});
|
||||
1353
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.ts
Normal file
1353
src/Explorer/Graph/GraphExplorerComponent/D3ForceGraph.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
src/Explorer/Graph/GraphExplorerComponent/EdgeInfoCache.ts
Normal file
41
src/Explorer/Graph/GraphExplorerComponent/EdgeInfoCache.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ObjectCache } from "../../../Common/ObjectCache";
|
||||
import { GremlinVertex, GraphData } from "./GraphData";
|
||||
|
||||
/**
|
||||
* Remember vertex edge information
|
||||
*/
|
||||
export class EdgeInfoCache extends ObjectCache<GremlinVertex> {
|
||||
/**
|
||||
* Add vertex to the cache. If already exists, merge all the edge information with the existing element
|
||||
* @param vertex
|
||||
*/
|
||||
public addVertex(vertex: GremlinVertex): void {
|
||||
let v: GremlinVertex;
|
||||
if (super.has(vertex.id)) {
|
||||
v = super.get(vertex.id);
|
||||
GraphData.addEdgeInfoToVertex(v, vertex);
|
||||
v._outEdgeIds = vertex._outEdgeIds;
|
||||
v._inEdgeIds = vertex._inEdgeIds;
|
||||
v._outEAllLoaded = vertex._outEAllLoaded;
|
||||
v._inEAllLoaded = vertex._inEAllLoaded;
|
||||
} else {
|
||||
v = vertex;
|
||||
}
|
||||
super.set(v.id, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the target is in the cache, retrieve edge information from cache and merge to this target
|
||||
* @param id
|
||||
*/
|
||||
public mergeEdgeInfo(target: GremlinVertex): void {
|
||||
if (super.has(target.id)) {
|
||||
const cachedVertex = super.get(target.id);
|
||||
GraphData.addEdgeInfoToVertex(target, cachedVertex);
|
||||
target._outEdgeIds = cachedVertex._outEdgeIds;
|
||||
target._inEdgeIds = cachedVertex._inEdgeIds;
|
||||
target._outEAllLoaded = cachedVertex._outEAllLoaded;
|
||||
target._inEAllLoaded = cachedVertex._inEAllLoaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Graph React component
|
||||
* Editor for neighbors (targets or sources)
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { NeighborVertexBasicInfo, EditedEdges, GraphNewEdgeData, PossibleVertex } from "./GraphExplorer";
|
||||
import { GraphUtil } from "./GraphUtil";
|
||||
import * as InputTypeaheadComponent from "../../Controls/InputTypeahead/InputTypeaheadComponent";
|
||||
import DeleteIcon from "../../../../images/delete.svg";
|
||||
import AddPropertyIcon from "../../../../images/Add-property.svg";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
|
||||
export interface EditorNeighborsComponentProps {
|
||||
isSource: boolean;
|
||||
editedNeighbors: EditedEdges;
|
||||
possibleVertices: PossibleVertex[];
|
||||
possibleEdgeLabels: InputTypeaheadComponent.Item[];
|
||||
onUpdateEdges: (editedEdges: EditedEdges, isSource: boolean) => void;
|
||||
}
|
||||
|
||||
export class EditorNeighborsComponent extends React.Component<EditorNeighborsComponentProps> {
|
||||
private static readonly SOURCE_TITLE = "Source";
|
||||
private static readonly TARGET_TITLE = "Target";
|
||||
private static readonly ADD_SOURCE = "Add Source";
|
||||
private static readonly ADD_TARGET = "Add Target";
|
||||
private static readonly ENTER_SOURCE = "Enter Source";
|
||||
private static readonly ENTER_TARGET = "Enter target";
|
||||
private static readonly DEFAULT_BLANK_VALUE = "";
|
||||
private addNewEdgeToNeighbor: (id: string) => void;
|
||||
|
||||
public constructor(props: EditorNeighborsComponentProps) {
|
||||
super(props);
|
||||
this.addNewEdgeToNeighbor = this.props.isSource ? this.addNewEdgeToSource : this.addNewEdgeToTarget;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
// Show empty text boxes by default if no neighbor for convenience
|
||||
if (this.props.editedNeighbors.currentNeighbors.length === 0) {
|
||||
if (this.props.isSource) {
|
||||
this.addNewEdgeToSource(this.props.editedNeighbors.vertexId);
|
||||
} else {
|
||||
this.addNewEdgeToTarget(this.props.editedNeighbors.vertexId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
const neighborTitle = this.props.isSource
|
||||
? EditorNeighborsComponent.SOURCE_TITLE
|
||||
: EditorNeighborsComponent.TARGET_TITLE;
|
||||
return (
|
||||
<table className="edgesTable">
|
||||
<thead className="propertyTableHeader">
|
||||
<tr>
|
||||
<td>{neighborTitle}</td>
|
||||
<td>Edge label</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.renderCurrentNeighborsFragment()}
|
||||
{this.renderAddedEdgesFragment()}
|
||||
<tr>
|
||||
<td colSpan={2} className="rightPaneAddPropertyBtnPadding">
|
||||
<AccessibleElement
|
||||
as="span"
|
||||
className="rightPaneAddPropertyBtn rightPaneBtns"
|
||||
aria-label="Add neighbor"
|
||||
onActivated={() => this.addNewEdgeToNeighbor(this.props.editedNeighbors.vertexId)}
|
||||
>
|
||||
<img src={AddPropertyIcon} alt="Add Property" />{" "}
|
||||
{this.props.isSource ? EditorNeighborsComponent.ADD_SOURCE : EditorNeighborsComponent.ADD_TARGET}
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
private onUpdateEdges(): void {
|
||||
this.props.onUpdateEdges(this.props.editedNeighbors, this.props.isSource);
|
||||
}
|
||||
|
||||
private removeCurrentNeighborEdge(index: number): void {
|
||||
let sources = this.props.editedNeighbors.currentNeighbors;
|
||||
let id = sources[index].edgeId;
|
||||
sources.splice(index, 1);
|
||||
|
||||
let droppedIds = this.props.editedNeighbors.droppedIds;
|
||||
droppedIds.push(id);
|
||||
this.onUpdateEdges();
|
||||
}
|
||||
|
||||
private removeAddedEdgeToNeighbor(index: number): void {
|
||||
this.props.editedNeighbors.addedEdges.splice(index, 1);
|
||||
this.onUpdateEdges();
|
||||
}
|
||||
|
||||
private addNewEdgeToSource(inV: string): void {
|
||||
this.props.editedNeighbors.addedEdges.push({
|
||||
inputInV: inV,
|
||||
inputOutV: EditorNeighborsComponent.DEFAULT_BLANK_VALUE,
|
||||
label: EditorNeighborsComponent.DEFAULT_BLANK_VALUE
|
||||
});
|
||||
this.onUpdateEdges();
|
||||
}
|
||||
|
||||
private addNewEdgeToTarget(outV: string): void {
|
||||
this.props.editedNeighbors.addedEdges.push({
|
||||
inputInV: EditorNeighborsComponent.DEFAULT_BLANK_VALUE,
|
||||
inputOutV: outV,
|
||||
label: EditorNeighborsComponent.DEFAULT_BLANK_VALUE
|
||||
});
|
||||
this.onUpdateEdges();
|
||||
}
|
||||
|
||||
private renderNeighborInputFragment(_edge: GraphNewEdgeData): JSX.Element {
|
||||
if (this.props.isSource) {
|
||||
return (
|
||||
<InputTypeaheadComponent.InputTypeaheadComponent
|
||||
defaultValue={_edge.inputOutV}
|
||||
choices={this.props.possibleVertices}
|
||||
onSelected={(newValue: InputTypeaheadComponent.Item) => {
|
||||
_edge.inputOutV = newValue.value;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
onNewValue={(newValue: string) => {
|
||||
_edge.inputOutV = newValue;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
placeholder={EditorNeighborsComponent.ENTER_SOURCE}
|
||||
typeaheadOverrideOptions={{ dynamic: false }}
|
||||
submitFct={(inputValue: string, selection: InputTypeaheadComponent.Item) => {
|
||||
_edge.inputOutV = inputValue || selection.value;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
showSearchButton={false}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InputTypeaheadComponent.InputTypeaheadComponent
|
||||
defaultValue={_edge.inputInV}
|
||||
choices={this.props.possibleVertices}
|
||||
onSelected={(newValue: InputTypeaheadComponent.Item) => {
|
||||
_edge.inputInV = newValue.value;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
onNewValue={(newValue: string) => {
|
||||
_edge.inputInV = newValue;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
placeholder={EditorNeighborsComponent.ENTER_TARGET}
|
||||
typeaheadOverrideOptions={{ dynamic: false }}
|
||||
submitFct={(inputValue: string, selection: InputTypeaheadComponent.Item) => {
|
||||
_edge.inputInV = inputValue || selection.value;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
showSearchButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderCurrentNeighborsFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.editedNeighbors.currentNeighbors.map((_neighbor: NeighborVertexBasicInfo, index: number) => (
|
||||
<tr key={`${index}_${_neighbor.id}_${_neighbor.edgeLabel}`}>
|
||||
<td>
|
||||
<span title={GraphUtil.getNeighborTitle(_neighbor)}>{_neighbor.name}</span>
|
||||
</td>
|
||||
<td className="labelCol">
|
||||
<span className="editSeeInPadding">{_neighbor.edgeLabel}</span>
|
||||
</td>
|
||||
<td className="actionCol">
|
||||
<AccessibleElement
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Remove current neighbor's edge"
|
||||
onActivated={() => this.removeCurrentNeighborEdge(index)}
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" />
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderAddedEdgesFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.editedNeighbors.addedEdges.map((_edge: GraphNewEdgeData, index: number) => (
|
||||
<tr key={`${_edge.inputInV}-${_edge.inputOutV}-${index}`}>
|
||||
<td className="valueCol">{this.renderNeighborInputFragment(_edge)}</td>
|
||||
<td className="labelCol">
|
||||
<InputTypeaheadComponent.InputTypeaheadComponent
|
||||
defaultValue={_edge.label}
|
||||
choices={this.props.possibleEdgeLabels}
|
||||
onSelected={(newValue: InputTypeaheadComponent.Item) => {
|
||||
_edge.label = newValue.value;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
onNewValue={(newValue: string) => {
|
||||
_edge.label = newValue;
|
||||
this.onUpdateEdges();
|
||||
}}
|
||||
placeholder={"Label"}
|
||||
typeaheadOverrideOptions={{ dynamic: false }}
|
||||
showSearchButton={false}
|
||||
/>
|
||||
</td>
|
||||
<td className="actionCol">
|
||||
<span className="rightPaneTrashIcon rightPaneBtns">
|
||||
<img src={DeleteIcon} alt="Delete" onClick={e => this.removeAddedEdgeToNeighbor(index)} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import { GraphHighlightedNodeData, EditedProperties } from "./GraphExplorer";
|
||||
|
||||
import { EditorNodePropertiesComponent, EditorNodePropertiesComponentProps } from "./EditorNodePropertiesComponent";
|
||||
|
||||
describe("<EditorNodePropertiesComponent />", () => {
|
||||
// Tests that: single value prop is rendered with a textbox and a delete button
|
||||
// multi-value prop only a delete button (cannot be edited)
|
||||
it("renders component", () => {
|
||||
const props: EditorNodePropertiesComponentProps = {
|
||||
editedProperties: {
|
||||
pkId: "id",
|
||||
readOnlyProperties: [
|
||||
{
|
||||
key: "singlevalueprop",
|
||||
values: [{ value: "abcd", type: "string" }]
|
||||
},
|
||||
{
|
||||
key: "multivaluesprop",
|
||||
values: [
|
||||
{ value: "efgh", type: "string" },
|
||||
{ value: 1234, type: "number" },
|
||||
{ value: true, type: "boolean" },
|
||||
{ value: false, type: "boolean" },
|
||||
{ value: undefined, type: "null" },
|
||||
{ value: null, type: "null" }
|
||||
]
|
||||
}
|
||||
],
|
||||
existingProperties: [
|
||||
{
|
||||
key: "singlevalueprop2",
|
||||
values: [{ value: "ijkl", type: "string" }]
|
||||
},
|
||||
{
|
||||
key: "multivaluesprop2",
|
||||
values: [
|
||||
{ value: "mnop", type: "string" },
|
||||
{ value: 5678, type: "number" },
|
||||
{ value: true, type: "boolean" },
|
||||
{ value: false, type: "boolean" },
|
||||
{ value: undefined, type: "null" },
|
||||
{ value: null, type: "null" }
|
||||
]
|
||||
}
|
||||
],
|
||||
addedProperties: [],
|
||||
droppedKeys: []
|
||||
},
|
||||
onUpdateProperties: (editedProperties: EditedProperties): void => {}
|
||||
};
|
||||
const wrapper = shallow(<EditorNodePropertiesComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders proper unicode", () => {
|
||||
const props: EditorNodePropertiesComponentProps = {
|
||||
editedProperties: {
|
||||
pkId: "id",
|
||||
readOnlyProperties: [
|
||||
{
|
||||
key: "unicode1",
|
||||
values: [{ value: "Véronique", type: "string" }]
|
||||
},
|
||||
{
|
||||
key: "unicode2",
|
||||
values: [{ value: "亜妃子", type: "string" }]
|
||||
}
|
||||
],
|
||||
existingProperties: [
|
||||
{
|
||||
key: "unicode1",
|
||||
values: [{ value: "André", type: "string" }]
|
||||
},
|
||||
{
|
||||
key: "unicode2",
|
||||
values: [{ value: "あきら, アキラ,安喜良", type: "string" }]
|
||||
}
|
||||
],
|
||||
addedProperties: [],
|
||||
droppedKeys: []
|
||||
},
|
||||
onUpdateProperties: (editedProperties: EditedProperties): void => {}
|
||||
};
|
||||
const wrapper = shallow(<EditorNodePropertiesComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Graph React component
|
||||
* Read-only properties
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { EditedProperties } from "./GraphExplorer";
|
||||
import DeleteIcon from "../../../../images/delete.svg";
|
||||
import AddIcon from "../../../../images/Add-property.svg";
|
||||
import { ReadOnlyNodePropertiesComponent } from "./ReadOnlyNodePropertiesComponent";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
|
||||
export interface EditorNodePropertiesComponentProps {
|
||||
editedProperties: EditedProperties;
|
||||
onUpdateProperties: (editedProperties: EditedProperties) => void;
|
||||
}
|
||||
|
||||
export class EditorNodePropertiesComponent extends React.Component<EditorNodePropertiesComponentProps> {
|
||||
public static readonly VERTEX_PROPERTY_TYPES = ["string", "number", "boolean" /* 'null' */]; // TODO Enable null when fully supported by backend
|
||||
private static readonly DEFAULT_PROPERTY_TYPE = "string";
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<table className="propertyTable">
|
||||
<tbody>
|
||||
{this.getReadOnlyPropertiesFragment()}
|
||||
{this.getEditedPropertiesFragment()}
|
||||
{this.getAddedPropertiesFragment()}
|
||||
<tr>
|
||||
<td colSpan={3} className="rightPaneAddPropertyBtnPadding">
|
||||
<AccessibleElement
|
||||
className="rightPaneAddPropertyBtn rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Add a property"
|
||||
onActivated={() => this.addProperty()}
|
||||
>
|
||||
<img src={AddIcon} alt="Add" /> Add Property
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
private removeExistingProperty(key: string): void {
|
||||
const editedProperties = this.props.editedProperties;
|
||||
// search for it
|
||||
for (let i = 0; i < editedProperties.existingProperties.length; i++) {
|
||||
let ip = editedProperties.existingProperties[i];
|
||||
if (ip.key === key) {
|
||||
editedProperties.existingProperties.splice(i, 1);
|
||||
editedProperties.droppedKeys.push(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.props.onUpdateProperties(editedProperties);
|
||||
}
|
||||
|
||||
private removeAddedProperty(index: number): void {
|
||||
const editedProperties = this.props.editedProperties;
|
||||
let ap = editedProperties.addedProperties;
|
||||
ap.splice(index, 1);
|
||||
|
||||
this.props.onUpdateProperties(editedProperties);
|
||||
}
|
||||
|
||||
private addProperty(): void {
|
||||
const editedProperties = this.props.editedProperties;
|
||||
let ap = editedProperties.addedProperties;
|
||||
ap.push({ key: "", values: [{ value: "", type: EditorNodePropertiesComponent.DEFAULT_PROPERTY_TYPE }] });
|
||||
this.props.onUpdateProperties(editedProperties);
|
||||
}
|
||||
|
||||
private getReadOnlyPropertiesFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.editedProperties.readOnlyProperties.map((nodeProp: ViewModels.InputProperty) =>
|
||||
ReadOnlyNodePropertiesComponent.renderReadOnlyPropertyKeyPair(
|
||||
nodeProp.key,
|
||||
nodeProp.values.map(val => val.value)
|
||||
)
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private getEditedPropertiesFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.editedProperties.existingProperties.map((nodeProp: ViewModels.InputProperty) => {
|
||||
// Check multiple values
|
||||
if (nodeProp.values && Array.isArray(nodeProp.values) && nodeProp.values.length === 1) {
|
||||
return this.renderEditableProperty(nodeProp.key, nodeProp.values[0]);
|
||||
} else {
|
||||
return this.renderNonEditableProperty(nodeProp);
|
||||
}
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private renderEditableProperty(key: string, singleValue: ViewModels.InputPropertyValue): JSX.Element {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="labelCol">{key}</td>
|
||||
<td className="valueCol">
|
||||
{singleValue.type !== "null" && (
|
||||
<input
|
||||
className="edgeInput"
|
||||
type="text"
|
||||
value={singleValue.value.toString()}
|
||||
placeholder="Value"
|
||||
onChange={e => {
|
||||
singleValue.value = e.target.value;
|
||||
this.props.onUpdateProperties(this.props.editedProperties);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="typeSelect"
|
||||
value={singleValue.type}
|
||||
onChange={e => {
|
||||
singleValue.type = e.target.value as ViewModels.InputPropertyValueTypeString;
|
||||
if (singleValue.type === "null") {
|
||||
singleValue.value = null;
|
||||
}
|
||||
this.props.onUpdateProperties(this.props.editedProperties);
|
||||
}}
|
||||
required
|
||||
>
|
||||
{EditorNodePropertiesComponent.VERTEX_PROPERTY_TYPES.map((type: string) => (
|
||||
<option value={type} key={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="actionCol">
|
||||
<AccessibleElement
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Delete property"
|
||||
onActivated={e => this.removeExistingProperty(key)}
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" />
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
private renderNonEditableProperty(nodeProp: ViewModels.InputProperty): JSX.Element {
|
||||
return (
|
||||
<tr key={nodeProp.key}>
|
||||
<td className="labelCol propertyId">{nodeProp.key}</td>
|
||||
<td>{nodeProp.values.map(value => ReadOnlyNodePropertiesComponent.renderSinglePropertyValue(value.value))}</td>
|
||||
<td />
|
||||
<td className="actionCol">
|
||||
<AccessibleElement
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Remove existing property"
|
||||
onActivated={e => this.removeExistingProperty(nodeProp.key)}
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" />
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For now, this assumes that we add only one value to a property
|
||||
*/
|
||||
private getAddedPropertiesFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.props.editedProperties.addedProperties.map((addedProperty: ViewModels.InputProperty, index: number) => {
|
||||
const firstValue = addedProperty.values[0];
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="labelCol">
|
||||
<input
|
||||
type="text"
|
||||
value={addedProperty.key}
|
||||
placeholder="Key"
|
||||
onChange={e => {
|
||||
addedProperty.key = e.target.value;
|
||||
this.props.onUpdateProperties(this.props.editedProperties);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="valueCol">
|
||||
{firstValue.type !== "null" && (
|
||||
<input
|
||||
className="edgeInput"
|
||||
type="text"
|
||||
value={firstValue.value.toString()}
|
||||
placeholder="Value"
|
||||
onChange={e => {
|
||||
firstValue.value = e.target.value;
|
||||
if (firstValue.type === "null") {
|
||||
firstValue.value = null;
|
||||
}
|
||||
this.props.onUpdateProperties(this.props.editedProperties);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="typeSelect"
|
||||
value={firstValue.type}
|
||||
onChange={e => {
|
||||
firstValue.type = e.target.value as ViewModels.InputPropertyValueTypeString;
|
||||
this.props.onUpdateProperties(this.props.editedProperties);
|
||||
}}
|
||||
required
|
||||
>
|
||||
{EditorNodePropertiesComponent.VERTEX_PROPERTY_TYPES.map((type: string) => (
|
||||
<option value={type} key={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="actionCol">
|
||||
<AccessibleElement
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Remove property"
|
||||
onActivated={e => this.removeAddedProperty(index)}
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" />
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
112
src/Explorer/Graph/GraphExplorerComponent/GraphData.test.ts
Normal file
112
src/Explorer/Graph/GraphExplorerComponent/GraphData.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { GraphData, GremlinVertex, GremlinEdge } from "./GraphData";
|
||||
|
||||
describe("Graph Data", () => {
|
||||
it("should set only one node as root", () => {
|
||||
const graphData = new GraphData<GremlinVertex, GremlinEdge>();
|
||||
const v1: GremlinVertex = { id: "1", label: null };
|
||||
const v2: GremlinVertex = { id: "2", label: null };
|
||||
const v3: GremlinVertex = { id: "3", label: null };
|
||||
v3._isRoot = true;
|
||||
|
||||
graphData.addVertex(v1);
|
||||
graphData.addVertex(v2);
|
||||
graphData.addVertex(v3);
|
||||
|
||||
graphData.setAsRoot("2");
|
||||
graphData.setAsRoot("1");
|
||||
|
||||
// Count occurences of roots
|
||||
const roots = graphData.vertices.filter((v: any) => {
|
||||
return v._isRoot;
|
||||
});
|
||||
|
||||
expect(roots.length).toBe(1);
|
||||
expect(graphData.getVertexById("1")._isRoot).toBeDefined();
|
||||
expect(graphData.getVertexById("2")._isRoot).not.toBeDefined();
|
||||
expect(graphData.getVertexById("3")._isRoot).not.toBeDefined();
|
||||
});
|
||||
|
||||
it("should properly find root id", () => {
|
||||
const graphData = new GraphData();
|
||||
const v1: GremlinVertex = { id: "1", label: null };
|
||||
const v2: GremlinVertex = { id: "2", label: null };
|
||||
const v3: GremlinVertex = { id: "3", label: null };
|
||||
|
||||
graphData.addVertex(v1);
|
||||
graphData.addVertex(v2);
|
||||
graphData.addVertex(v3);
|
||||
|
||||
graphData.setAsRoot("1");
|
||||
|
||||
expect(graphData.findRootNodeId()).toBe("1");
|
||||
});
|
||||
|
||||
it("should remove edge from graph", () => {
|
||||
const graphData = new GraphData();
|
||||
|
||||
graphData.addVertex({ id: "v1", label: null });
|
||||
graphData.addVertex({ id: "v2", label: null });
|
||||
graphData.addVertex({ id: "v3", label: null });
|
||||
|
||||
graphData.addEdge({ id: "e1", inV: "v1", outV: "v2", label: null });
|
||||
graphData.addEdge({ id: "e2", inV: "v1", outV: "v3", label: null });
|
||||
|
||||
// in edge
|
||||
graphData.removeEdge("e1", false);
|
||||
expect(graphData.edges.length).toBe(1);
|
||||
expect(graphData).not.toContain(jasmine.objectContaining({ id: "e1" }));
|
||||
|
||||
// out edge
|
||||
graphData.removeEdge("e2", false);
|
||||
expect(graphData.edges.length).toBe(0);
|
||||
expect(graphData).not.toContain(jasmine.objectContaining({ id: "e2" }));
|
||||
});
|
||||
|
||||
it("should get string node property", () => {
|
||||
const stringValue = "blah";
|
||||
const value = GraphData.getNodePropValue(
|
||||
{
|
||||
id: "id",
|
||||
label: "label",
|
||||
properties: {
|
||||
testString: [{ id: "123", value: stringValue }]
|
||||
}
|
||||
},
|
||||
"testString"
|
||||
);
|
||||
|
||||
expect(value).toEqual(stringValue);
|
||||
});
|
||||
|
||||
it("should get number node property", () => {
|
||||
const numberValue = 2;
|
||||
const value = GraphData.getNodePropValue(
|
||||
{
|
||||
id: "id",
|
||||
label: "label",
|
||||
properties: {
|
||||
testString: [{ id: "123", value: numberValue }]
|
||||
}
|
||||
},
|
||||
"testString"
|
||||
);
|
||||
|
||||
expect(value).toEqual(numberValue);
|
||||
});
|
||||
|
||||
it("should get boolean node property", () => {
|
||||
const booleanValue = true;
|
||||
const value = GraphData.getNodePropValue(
|
||||
{
|
||||
id: "id",
|
||||
label: "label",
|
||||
properties: {
|
||||
testString: [{ id: "123", value: booleanValue }]
|
||||
}
|
||||
},
|
||||
"testString"
|
||||
);
|
||||
|
||||
expect(value).toEqual(booleanValue);
|
||||
});
|
||||
});
|
||||
521
src/Explorer/Graph/GraphExplorerComponent/GraphData.ts
Normal file
521
src/Explorer/Graph/GraphExplorerComponent/GraphData.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import _ from "underscore";
|
||||
import { SimulationNodeDatum, SimulationLinkDatum } from "d3";
|
||||
|
||||
export interface PaginationInfo {
|
||||
total: number;
|
||||
|
||||
// Note: end is the upper-bound outside of the page range.
|
||||
currentPage: { start: number; end: number };
|
||||
}
|
||||
|
||||
export interface GremlinVertex {
|
||||
id: string;
|
||||
label: string;
|
||||
inE?: { [label: string]: GremlinShortInEdge[] };
|
||||
outE?: { [label: string]: GremlinShortOutEdge[] };
|
||||
properties?: { [propName: string]: GremlinProperty[] };
|
||||
|
||||
// Private use. Not part of Gremlin's specs
|
||||
_isRoot?: boolean;
|
||||
_isFixedPosition?: boolean;
|
||||
_pagination?: PaginationInfo;
|
||||
_ancestorsId?: string[];
|
||||
_inEAllLoaded?: boolean;
|
||||
_outEAllLoaded?: boolean;
|
||||
_outEdgeIds?: string[];
|
||||
_inEdgeIds?: string[];
|
||||
}
|
||||
|
||||
export interface GremlinShortInEdge {
|
||||
id: string;
|
||||
outV: string;
|
||||
}
|
||||
|
||||
export interface GremlinShortOutEdge {
|
||||
id: string;
|
||||
inV: string;
|
||||
}
|
||||
|
||||
export interface GremlinEdge {
|
||||
id: string;
|
||||
inV: string;
|
||||
outV: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GremlinProperty {
|
||||
id: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface MapArray {
|
||||
[id: string]: string[];
|
||||
}
|
||||
|
||||
/*
|
||||
* D3 adds fields (such as x,y, ...) to the original vertices and edges.
|
||||
* For D3 purposes, we extend GremlinVertex and GremlinEdge to support d3's functionality.
|
||||
* By keeping GremlinVertex, GremlinEdge different from D3Node and D3Link, we can decouple
|
||||
* the Gremlin code/cosmosdb code (in GraphExplorerComponents.ts) from the
|
||||
* D3 visualization implementation (in D3ForceGraph.ts).
|
||||
*
|
||||
* GraphData's logic works with both type pairs.
|
||||
*/
|
||||
export interface D3Node extends GremlinVertex, SimulationNodeDatum {}
|
||||
export interface D3Link extends GremlinEdge, SimulationLinkDatum<D3Node> {}
|
||||
|
||||
/**
|
||||
* Functionality related to graph manipulation
|
||||
*/
|
||||
export class GraphData<V extends GremlinVertex, E extends GremlinEdge> {
|
||||
private _vertices: V[];
|
||||
private _edges: E[];
|
||||
|
||||
// Keep track of neighbors' id
|
||||
private _targetsMap: MapArray;
|
||||
private _sourcesMap: MapArray;
|
||||
|
||||
// Lookup vertex name by id
|
||||
private _id2VertexMap: { [id: string]: V };
|
||||
|
||||
constructor() {
|
||||
this._vertices = [];
|
||||
this._edges = [];
|
||||
this._targetsMap = {};
|
||||
this._sourcesMap = {};
|
||||
this._id2VertexMap = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy from plain object
|
||||
* @param data
|
||||
*/
|
||||
public setData(data: any) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const props = ["_vertices", "_edges", "_targetsMap", "_sourcesMap", "_id2VertexMap"];
|
||||
for (let i = 0; i < props.length; i++) {
|
||||
if (!data.hasOwnProperty(props[i])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._vertices = data._vertices;
|
||||
this._edges = data._edges;
|
||||
this._targetsMap = data._targetsMap;
|
||||
this._sourcesMap = data._sourcesMap;
|
||||
this._id2VertexMap = data._id2VertexMap;
|
||||
}
|
||||
|
||||
public get vertices(): V[] {
|
||||
return this._vertices;
|
||||
}
|
||||
|
||||
public get edges(): E[] {
|
||||
return this._edges;
|
||||
}
|
||||
|
||||
public getVertexById(id: string): V {
|
||||
return this._id2VertexMap[id];
|
||||
}
|
||||
|
||||
public hasVertexId(id: string): boolean {
|
||||
return this._id2VertexMap.hasOwnProperty(id);
|
||||
}
|
||||
|
||||
public get ids(): string[] {
|
||||
return Object.keys(this._id2VertexMap);
|
||||
}
|
||||
|
||||
public addVertex(vertex: V) {
|
||||
if (this.ids.indexOf(vertex.id) !== -1) {
|
||||
// Make sure vertex is not already in
|
||||
return;
|
||||
}
|
||||
this._vertices.push(vertex);
|
||||
this._id2VertexMap[vertex.id] = vertex;
|
||||
|
||||
this.addNeighborInfo(vertex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look in this inE and outE and update nodes already in the graph
|
||||
* @param vertex
|
||||
*/
|
||||
public addNeighborInfo(vertex: V) {
|
||||
// Add edge if the other end is in the graph
|
||||
if (vertex.hasOwnProperty("inE")) {
|
||||
for (let p in vertex["inE"]) {
|
||||
vertex["inE"][p].forEach((e: GremlinShortInEdge) => {
|
||||
if (this.hasVertexId(e.outV)) {
|
||||
const v = this.getVertexById(e.outV);
|
||||
GraphData.addOutE(v, p, {
|
||||
id: e.id,
|
||||
inV: e.outV
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (vertex.hasOwnProperty("outE")) {
|
||||
for (let p in vertex["outE"]) {
|
||||
vertex["outE"][p].forEach((e: GremlinShortOutEdge) => {
|
||||
if (this.hasVertexId(e.inV)) {
|
||||
const v = this.getVertexById(e.inV);
|
||||
GraphData.addInE(v, p, {
|
||||
id: e.id,
|
||||
outV: e.inV
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getSourcesForId(id: string): string[] {
|
||||
return this._sourcesMap[id];
|
||||
}
|
||||
|
||||
public getTargetsForId(id: string): string[] {
|
||||
return this._targetsMap[id];
|
||||
}
|
||||
|
||||
public addEdge(edge: E): void {
|
||||
// Check if edge is not already in
|
||||
for (let i = 0; i < this._edges.length; i++) {
|
||||
if (this._edges[i].inV === edge.inV && this._edges[i].outV === edge.outV) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add edge only if both ends of the edge are in the graph
|
||||
if (this.hasVertexId(edge.inV) && this.hasVertexId(edge.outV)) {
|
||||
this._edges.push(edge);
|
||||
}
|
||||
GraphData.addToMap(this._targetsMap, edge.outV, edge.inV);
|
||||
GraphData.addToMap(this._sourcesMap, edge.inV, edge.outV);
|
||||
|
||||
// Add edge info to vertex
|
||||
if (this.hasVertexId(edge.inV)) {
|
||||
GraphData.addInE(this.getVertexById(edge.inV), edge.label, edge);
|
||||
}
|
||||
if (this.hasVertexId(edge.outV)) {
|
||||
GraphData.addOutE(this.getVertexById(edge.outV), edge.label, edge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add inE edge to vertex
|
||||
* @param v
|
||||
* @param edge
|
||||
*/
|
||||
public static addInE(v: GremlinVertex, label: string, edge: GremlinShortInEdge) {
|
||||
v["inE"] = v["inE"] || {};
|
||||
v["inE"][label] = v["inE"][label] || [];
|
||||
GraphData.addToEdgeArray(edge, v["inE"][label]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add outE edge to vertex
|
||||
* @param v
|
||||
* @param edge
|
||||
*/
|
||||
public static addOutE(v: GremlinVertex, label: string, edge: GremlinShortOutEdge) {
|
||||
v["outE"] = v["outE"] || {};
|
||||
v["outE"][label] = v["outE"][label] || [];
|
||||
GraphData.addToEdgeArray(edge, v["outE"][label]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure to not add the same edge if already exists
|
||||
* TODO Unit test this!
|
||||
* @param edge
|
||||
* @param edgeArray
|
||||
*/
|
||||
public static addToEdgeArray(
|
||||
edge: GremlinShortInEdge | GremlinShortOutEdge,
|
||||
edgeArray: (GremlinShortInEdge | GremlinShortOutEdge)[]
|
||||
) {
|
||||
for (let i = 0; i < edgeArray.length; i++) {
|
||||
if (edgeArray[i].id === edge.id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
edgeArray.push(edge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all inE and outE from source to target
|
||||
* @param target
|
||||
* @param source
|
||||
*/
|
||||
public static addEdgeInfoToVertex(target: GremlinVertex, source: GremlinVertex): void {
|
||||
if (source.hasOwnProperty("outE")) {
|
||||
for (let p in source.outE) {
|
||||
source.outE[p].forEach((e: GremlinShortOutEdge) => {
|
||||
GraphData.addOutE(target, p, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (source.hasOwnProperty("inE")) {
|
||||
for (let p in source.inE) {
|
||||
source.inE[p].forEach((e: GremlinShortInEdge) => {
|
||||
GraphData.addInE(target, p, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public unloadAllVertices(excludedIds: string[]): void {
|
||||
this.ids.forEach((id: string) => {
|
||||
if (excludedIds.indexOf(id) !== -1) {
|
||||
return;
|
||||
}
|
||||
this.removeVertex(id, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* IE doesn't support Object.values(object)
|
||||
* @param object
|
||||
*/
|
||||
public static getValues(object: any) {
|
||||
if (!object) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(object).map((p: string) => {
|
||||
return object[p];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase all references to vertex in graph
|
||||
* @param id
|
||||
* @param unloadOnly true: unload from cache. Delete edge and vertices, but not the references to it in the vertices
|
||||
*/
|
||||
public removeVertex(id: string, unloadOnly: boolean): void {
|
||||
if (!this.hasVertexId(id)) {
|
||||
console.error("No vertex to delete found with id", id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all edges that touches this vertex and remove them
|
||||
let edgeIds: string[] = [];
|
||||
this._edges.forEach((edge: E) => {
|
||||
if (edge.inV === id || edge.outV === id) {
|
||||
edgeIds.push(edge.id);
|
||||
}
|
||||
});
|
||||
|
||||
edgeIds.forEach((id: string) => {
|
||||
this.removeEdge(id, unloadOnly);
|
||||
});
|
||||
|
||||
GraphData.removeFromMap(this._sourcesMap, id);
|
||||
GraphData.removeFromMap(this._targetsMap, id);
|
||||
|
||||
// Delete from the map
|
||||
this.deleteVertex(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove edge from graph data
|
||||
* @param edgeId
|
||||
* @param unloadOnly remove edge, but not references in the nodes
|
||||
*/
|
||||
public removeEdge(edgeId: string, unloadOnly: boolean) {
|
||||
// Remove from edges array
|
||||
for (let i = 0; i < this._edges.length; i++) {
|
||||
if (this._edges[i].id === edgeId) {
|
||||
const edge = this._edges[i];
|
||||
this._edges.splice(i, 1);
|
||||
GraphData.removeEltFromMap(this._sourcesMap, edge.inV, edge.outV);
|
||||
GraphData.removeEltFromMap(this._targetsMap, edge.outV, edge.inV);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!unloadOnly) {
|
||||
// Cleanup vertices
|
||||
this._vertices.forEach((vertex: GremlinVertex) => {
|
||||
GraphData.getValues(vertex.inE).forEach((edges: GremlinShortInEdge[]) => {
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (edges[i].id === edgeId) {
|
||||
edges.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
GraphData.getValues(vertex.outE).forEach((edges: GremlinShortOutEdge[]) => {
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (edges[i].id === edgeId) {
|
||||
edges.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this node as root, clear root tag for other nodes
|
||||
* @param id
|
||||
*/
|
||||
public setAsRoot(id: string) {
|
||||
this._vertices.forEach((v: V) => {
|
||||
delete v._isRoot;
|
||||
delete v._isFixedPosition;
|
||||
});
|
||||
if (this.hasVertexId(id)) {
|
||||
const v = this.getVertexById(id);
|
||||
v._isRoot = true;
|
||||
v._isFixedPosition = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find root node id
|
||||
* @return root node id if found, undefined otherwise
|
||||
*/
|
||||
public findRootNodeId(): string {
|
||||
return (
|
||||
_.find(this._vertices, (v: GremlinVertex) => {
|
||||
return !!v._isRoot;
|
||||
}) || ({} as any)
|
||||
).id;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param v
|
||||
* @return list of edge ids for this vertex
|
||||
*/
|
||||
public static getEdgesId(v: GremlinVertex): string[] {
|
||||
const ids: string[] = [];
|
||||
if (v.inE) {
|
||||
for (var l in v.inE) {
|
||||
v.inE[l].forEach((e: GremlinShortInEdge) => ids.push(e.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (v.outE) {
|
||||
for (var l in v.outE) {
|
||||
v.outE[l].forEach((e: GremlinShortOutEdge) => ids.push(e.id));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve node's property value
|
||||
* @param node
|
||||
* @param prop
|
||||
*/
|
||||
public static getNodePropValue(node: D3Node, prop: string): string | number | boolean {
|
||||
if (node.hasOwnProperty(prop)) {
|
||||
return (node as any)[prop];
|
||||
}
|
||||
|
||||
// This is DocDB specific
|
||||
if (node.hasOwnProperty("properties") && node.properties.hasOwnProperty(prop)) {
|
||||
return node.properties[prop][0]["value"];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new value to the key-values map
|
||||
* key <--> [ value1, value2, ... ]
|
||||
* @param kvmap
|
||||
* @param key
|
||||
* @param value
|
||||
*/
|
||||
private static addToMap(kvmap: { [id: string]: string[] }, key: string, value: string): void {
|
||||
var values: string[] = [];
|
||||
if (kvmap.hasOwnProperty(key)) {
|
||||
values = kvmap[key];
|
||||
} else {
|
||||
kvmap[key] = values;
|
||||
}
|
||||
if (values.indexOf(value) === -1) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteVertex(id: string): void {
|
||||
const v = this.getVertexById(id);
|
||||
const n = this.vertices.indexOf(v);
|
||||
this._vertices.splice(n, 1);
|
||||
|
||||
delete this._id2VertexMap[id];
|
||||
}
|
||||
|
||||
private static removeIdFromArray(idArray: string[], id2remove: string) {
|
||||
const n = idArray.indexOf(id2remove);
|
||||
if (n !== -1) {
|
||||
idArray.splice(n, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove id from map
|
||||
* Note: map may end up with empty arrays
|
||||
* @param map
|
||||
* @param id2remove
|
||||
*/
|
||||
private static removeFromMap(map: MapArray, id2remove: string) {
|
||||
// First remove entry if it exists
|
||||
if (map.hasOwnProperty(id2remove)) {
|
||||
delete map[id2remove];
|
||||
}
|
||||
|
||||
// Then remove element if it's in any array
|
||||
GraphData.getValues(map).forEach((idArray: string[]) => {
|
||||
GraphData.removeIdFromArray(idArray, id2remove);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove value2remove for entryId in map
|
||||
* @param map
|
||||
* @param entryId
|
||||
* @param value2Remove
|
||||
*/
|
||||
private static removeEltFromMap(map: MapArray, entryId: string, id2remove: string) {
|
||||
const idArray = map[entryId];
|
||||
if (!idArray || idArray.length < 1) {
|
||||
return;
|
||||
}
|
||||
GraphData.removeIdFromArray(idArray, id2remove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of children ids of a given vertex
|
||||
* @param vertex
|
||||
*/
|
||||
private static getChildrenId(vertex: GremlinVertex): string[] {
|
||||
const ids = <any>{}; // HashSet
|
||||
if (vertex.hasOwnProperty("outE")) {
|
||||
let outE = vertex.outE;
|
||||
for (var label in outE) {
|
||||
for (let i = 0; i < outE[label].length; i++) {
|
||||
let edge = outE[label][i];
|
||||
ids[edge.inV] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vertex.hasOwnProperty("inE")) {
|
||||
let inE = vertex.inE;
|
||||
for (var label in inE) {
|
||||
for (let i = 0; i < inE[label].length; i++) {
|
||||
let edge = inE[label][i];
|
||||
ids[edge.outV] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(ids);
|
||||
}
|
||||
}
|
||||
694
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.test.tsx
Normal file
694
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.test.tsx
Normal file
@@ -0,0 +1,694 @@
|
||||
import React from "react";
|
||||
import * as sinon from "sinon";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import * as Q from "q";
|
||||
import "../../../../externals/jquery.typeahead.min";
|
||||
import { GraphExplorer, GraphExplorerProps, GraphAccessor, GraphHighlightedNodeData } from "./GraphExplorer";
|
||||
import * as D3ForceGraph from "./D3ForceGraph";
|
||||
import { GraphData } from "./GraphData";
|
||||
import { TabComponent } from "../../Controls/Tabs/TabComponent";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as StorageUtility from "../../../Shared/StorageUtility";
|
||||
import GraphTab from "../../Tabs/GraphTab";
|
||||
import DocumentClientUtilityBase from "../../../Common/DocumentClientUtilityBase";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
|
||||
describe("Check whether query result is vertex array", () => {
|
||||
it("should reject null as vertex array", () => {
|
||||
expect(GraphExplorer.isVerticesNonEmptyArray(null)).toBe(false);
|
||||
});
|
||||
it("should accept empty array as vertex array", () => {
|
||||
expect(GraphExplorer.isVerticesNonEmptyArray([])).toBe(true);
|
||||
});
|
||||
it("should reject object with primitives as vertex array", () => {
|
||||
expect(GraphExplorer.isVerticesNonEmptyArray([1, "2"])).toBe(false);
|
||||
});
|
||||
it("should reject results missing type as vertex array", () => {
|
||||
expect(GraphExplorer.isVerticesNonEmptyArray([{ id: "1" }])).toBe(false);
|
||||
});
|
||||
it("should check valid vertex array", () => {
|
||||
expect(GraphExplorer.isVerticesNonEmptyArray([{ id: "1", type: "vertex" }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Check whether query result is edge-vertex array", () => {
|
||||
it("should reject null as edge-vertex array", () => {
|
||||
expect(GraphExplorer.isEdgeVertexPairArray(null)).toBe(false);
|
||||
});
|
||||
it("should accept empty array as edge-vertex array", () => {
|
||||
expect(GraphExplorer.isEdgeVertexPairArray([])).toBe(true);
|
||||
});
|
||||
it("should reject object with primitives as edge-vertex array", () => {
|
||||
expect(GraphExplorer.isEdgeVertexPairArray([1, "2"])).toBe(false);
|
||||
});
|
||||
it("should reject results missing types as edge-vertex array", () => {
|
||||
expect(GraphExplorer.isEdgeVertexPairArray([{ e: {}, v: {} }])).toBe(false);
|
||||
});
|
||||
it("should check valid edge-vertex array", () => {
|
||||
expect(
|
||||
GraphExplorer.isEdgeVertexPairArray([
|
||||
{
|
||||
e: { id: "ide", type: "edge" },
|
||||
v: { id: "idv", type: "vertex" }
|
||||
}
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Create proper pkid pair", () => {
|
||||
it("should enclose string pk with quotes", () => {
|
||||
expect(GraphExplorer.generatePkIdPair("test", "id")).toEqual('["test", "id"]');
|
||||
});
|
||||
it("should not enclose non-string pk with quotes", () => {
|
||||
expect(GraphExplorer.generatePkIdPair(2, "id")).toEqual('[2, "id"]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("GraphExplorer", () => {
|
||||
const COLLECTION_RID = "collectionRid";
|
||||
const COLLECTION_SELF_LINK = "collectionSelfLink";
|
||||
const gremlinRU = 789.12;
|
||||
|
||||
const createMockProps = (documentClientUtility?: any): GraphExplorerProps => {
|
||||
const graphConfig = GraphTab.createGraphConfig();
|
||||
const graphConfigUi = GraphTab.createGraphConfigUiData(graphConfig);
|
||||
|
||||
return {
|
||||
onGraphAccessorCreated: (instance: GraphAccessor): void => {},
|
||||
onIsNewVertexDisabledChange: (isEnabled: boolean): void => {},
|
||||
onIsPropertyEditing: (isEditing: boolean): void => {},
|
||||
onIsGraphDisplayed: (isDisplayed: boolean): void => {},
|
||||
onResetDefaultGraphConfigValues: (): void => {},
|
||||
|
||||
onIsFilterQueryLoadingChange: (isFilterQueryLoading: boolean): void => {},
|
||||
onIsValidQueryChange: (isValidQuery: boolean): void => {},
|
||||
|
||||
collectionPartitionKeyProperty: "collectionPartitionKeyProperty",
|
||||
documentClientUtility: documentClientUtility,
|
||||
collectionRid: COLLECTION_RID,
|
||||
collectionSelfLink: COLLECTION_SELF_LINK,
|
||||
graphBackendEndpoint: "graphBackendEndpoint",
|
||||
databaseId: "databaseId",
|
||||
collectionId: "collectionId",
|
||||
masterKey: "masterKey",
|
||||
|
||||
onLoadStartKey: 0,
|
||||
onLoadStartKeyChange: (newKey: number): void => {},
|
||||
resourceId: "resourceId",
|
||||
|
||||
/* TODO Figure out how to make this Knockout-free */
|
||||
graphConfigUiData: graphConfigUi,
|
||||
graphConfig: graphConfig
|
||||
};
|
||||
};
|
||||
|
||||
describe("Initial rendering", () => {
|
||||
it("should load graph", () => {
|
||||
const props: GraphExplorerProps = createMockProps();
|
||||
const wrapper = mount(<GraphExplorer {...props} />);
|
||||
expect(wrapper.exists(".loadGraphBtn")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("should not display graph json switch", () => {
|
||||
const props: GraphExplorerProps = createMockProps();
|
||||
const wrapper = mount(<GraphExplorer {...props} />);
|
||||
expect(wrapper.find(TabComponent).length).toBe(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Behavior", () => {
|
||||
let graphExplorerInstance: GraphExplorer;
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
let connectStub: sinon.SinonSpy;
|
||||
let queryDocStub: sinon.SinonSpy;
|
||||
let submitToBackendSpy: sinon.SinonSpy;
|
||||
let renderResultAsJsonStub: sinon.SinonSpy;
|
||||
let onMiddlePaneInitializedStub: sinon.SinonSpy;
|
||||
let mockGraphRenderer: D3ForceGraph.GraphRenderer;
|
||||
|
||||
const DOCDB_G_DOT_V_QUERY =
|
||||
"select root.id, root.collectionPartitionKeyProperty from root where IS_DEFINED(root._isEdge) = false order by root._ts asc"; // g.V() in docdb
|
||||
const gVRU = 123.456;
|
||||
|
||||
const disableMonacoEditor = (graphExplorer: GraphExplorer) => {
|
||||
renderResultAsJsonStub = sinon.stub(graphExplorer, "renderResultAsJson").callsFake(
|
||||
(): JSX.Element => {
|
||||
return <div>[Monaco Editor Stub]</div>;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
interface AjaxResponse {
|
||||
response: any;
|
||||
isLast: boolean; // to indicate when to call done()
|
||||
}
|
||||
|
||||
interface BackendResponses {
|
||||
[query: string]: AjaxResponse;
|
||||
}
|
||||
|
||||
const createDocumentClientUtilityMock = (docDBResponse: AjaxResponse) => {
|
||||
const mock = {
|
||||
queryDocuments: () => {},
|
||||
queryDocumentsPage: (
|
||||
rid: string,
|
||||
iterator: any,
|
||||
firstItemIndex: number,
|
||||
options: any
|
||||
): Q.Promise<ViewModels.QueryResults> => {
|
||||
const qresult = {
|
||||
hasMoreResults: false,
|
||||
firstItemIndex: firstItemIndex,
|
||||
lastItemIndex: 0,
|
||||
itemCount: 0,
|
||||
documents: docDBResponse.response,
|
||||
activityId: "",
|
||||
headers: [] as any[],
|
||||
requestCharge: gVRU
|
||||
};
|
||||
|
||||
return Q.resolve(qresult);
|
||||
}
|
||||
};
|
||||
|
||||
const fakeIterator: any = {
|
||||
nextItem: (callback: (error: any, document: DataModels.DocumentId) => void): void => {},
|
||||
hasMoreResults: () => false,
|
||||
executeNext: (callback: (error: any, documents: DataModels.DocumentId[], headers: any) => void): void => {}
|
||||
};
|
||||
|
||||
queryDocStub = sinon.stub(mock, "queryDocuments").callsFake(
|
||||
(container: ViewModels.DocumentRequestContainer, query: string, options: any): Q.Promise<any> => {
|
||||
(fakeIterator as any)._query = query;
|
||||
return Q.resolve(fakeIterator);
|
||||
}
|
||||
);
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
const setupMocks = (
|
||||
graphExplorer: GraphExplorer,
|
||||
backendResponses: BackendResponses,
|
||||
done: any,
|
||||
ignoreD3Update: boolean
|
||||
) => {
|
||||
const complete = (): void => {
|
||||
wrapper.update();
|
||||
done();
|
||||
};
|
||||
|
||||
submitToBackendSpy = sinon.spy(graphExplorer, "submitToBackend");
|
||||
|
||||
disableMonacoEditor(graphExplorer);
|
||||
|
||||
// Calling this d3 function makes nodejs barf. Disable.
|
||||
onMiddlePaneInitializedStub = sinon.stub(graphExplorer, "onMiddlePaneInitialized").callsFake(() => {
|
||||
// Stub instance of graph renderer
|
||||
mockGraphRenderer = {
|
||||
selectNode: sinon.spy(),
|
||||
resetZoom: sinon.spy(),
|
||||
updateGraph: sinon.stub().callsFake(() => complete()),
|
||||
enableHighlight: sinon.spy()
|
||||
};
|
||||
graphExplorer.d3ForceGraph = mockGraphRenderer;
|
||||
});
|
||||
|
||||
const client = graphExplorer.gremlinClient.client;
|
||||
connectStub = sinon.stub(client, "connect").callsFake(() => {
|
||||
for (let requestId in client.requestsToSend) {
|
||||
const requestArgs = client.requestsToSend[requestId].args;
|
||||
const query = (requestArgs as any).gremlin;
|
||||
const backendResponse = backendResponses[query];
|
||||
if (!backendResponse) {
|
||||
console.error(`Unknown query ${query}. FIX YOUR UNIT TEST.`);
|
||||
Object.keys(backendResponses).forEach((k: string) => {
|
||||
console.log(`backendresponses: ${k} = ${backendResponses[k]}`);
|
||||
});
|
||||
complete();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
delete client.requestsToSend[requestId];
|
||||
client.params.successCallback({
|
||||
requestId: requestId,
|
||||
data: backendResponse.response,
|
||||
requestCharge: gremlinRU
|
||||
});
|
||||
|
||||
if (backendResponse.isLast) {
|
||||
if (ignoreD3Update) {
|
||||
setTimeout(() => complete(), 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createFetchOutEQuery = (vertexId: string, limit: number): string => {
|
||||
return `g.V("${vertexId}").outE().limit(${limit}).as('e').inV().as('v').select('e', 'v')`;
|
||||
};
|
||||
|
||||
const createFetchInEQuery = (vertexId: string, limit: number): string => {
|
||||
return `g.V("${vertexId}").inE().limit(${limit}).as('e').outV().as('v').select('e', 'v')`;
|
||||
};
|
||||
|
||||
const isVisible = (selector: string): boolean => {
|
||||
return wrapper.exists(selector);
|
||||
};
|
||||
|
||||
const bringUpGraphExplorer = (
|
||||
docDBResponse: AjaxResponse,
|
||||
backendResponses: BackendResponses,
|
||||
done: any,
|
||||
ignoreD3Update: boolean
|
||||
): GraphExplorer => {
|
||||
const props: GraphExplorerProps = createMockProps(createDocumentClientUtilityMock(docDBResponse));
|
||||
wrapper = mount(<GraphExplorer {...props} />);
|
||||
graphExplorerInstance = wrapper.instance() as GraphExplorer;
|
||||
setupMocks(graphExplorerInstance, backendResponses, done, ignoreD3Update);
|
||||
return graphExplorerInstance;
|
||||
};
|
||||
|
||||
const cleanUpStubsWrapper = () => {
|
||||
queryDocStub.restore();
|
||||
connectStub.restore();
|
||||
submitToBackendSpy.restore();
|
||||
renderResultAsJsonStub.restore();
|
||||
onMiddlePaneInitializedStub.restore();
|
||||
wrapper.unmount();
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
StorageUtility.LocalStorageUtility.setEntryString(StorageUtility.StorageKey.IsCrossPartitionQueryEnabled, "true");
|
||||
});
|
||||
|
||||
describe("Load Graph button", () => {
|
||||
beforeEach(async done => {
|
||||
const backendResponses: BackendResponses = {};
|
||||
backendResponses["g.V()"] = backendResponses['g.V("1")'] = {
|
||||
response: [{ id: "1", type: "vertex" }],
|
||||
isLast: false
|
||||
};
|
||||
backendResponses[createFetchOutEQuery("1", GraphExplorer.LOAD_PAGE_SIZE + 1)] = { response: [], isLast: false };
|
||||
backendResponses[createFetchInEQuery("1", GraphExplorer.LOAD_PAGE_SIZE + 1)] = { response: [], isLast: true };
|
||||
|
||||
const docDBResponse: AjaxResponse = { response: [{ id: "1" }], isLast: false };
|
||||
|
||||
bringUpGraphExplorer(docDBResponse, backendResponses, done, false);
|
||||
|
||||
wrapper.find(".loadGraphBtn button").simulate("click");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanUpStubsWrapper();
|
||||
});
|
||||
|
||||
it("should not submit g.V() to websocket", () => {
|
||||
expect((graphExplorerInstance.submitToBackend as sinon.SinonSpy).calledWith("g.V()")).toBe(false);
|
||||
});
|
||||
|
||||
it("should submit g.V() as docdb query with proper query", () => {
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[2]
|
||||
).toBe(DOCDB_G_DOT_V_QUERY);
|
||||
});
|
||||
|
||||
it("should submit g.V() as docdb query with proper parameters", () => {
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[0]
|
||||
).toEqual("databaseId");
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[1]
|
||||
).toEqual("collectionId");
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[3]
|
||||
).toEqual({ maxItemCount: GraphExplorer.ROOT_LIST_PAGE_SIZE, enableCrossPartitionQuery: true });
|
||||
});
|
||||
|
||||
it("should call backend thrice (user query, fetch outE, then fetch inE)", () => {
|
||||
expect(connectStub.callCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Execute Gremlin Query button", () => {
|
||||
beforeEach(done => {
|
||||
const backendResponses: BackendResponses = {};
|
||||
backendResponses["g.V()"] = backendResponses['g.V("2")'] = {
|
||||
response: [{ id: "2", type: "vertex" }],
|
||||
isLast: false
|
||||
};
|
||||
backendResponses[createFetchOutEQuery("2", GraphExplorer.LOAD_PAGE_SIZE + 1)] = { response: [], isLast: false };
|
||||
backendResponses[createFetchInEQuery("2", GraphExplorer.LOAD_PAGE_SIZE + 1)] = { response: [], isLast: true };
|
||||
|
||||
const docDBResponse: AjaxResponse = { response: [{ id: "2" }], isLast: false };
|
||||
|
||||
bringUpGraphExplorer(docDBResponse, backendResponses, done, false);
|
||||
|
||||
wrapper.find("button.queryButton").simulate("click");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanUpStubsWrapper();
|
||||
});
|
||||
|
||||
it("should not submit g.V() to websocket", () => {
|
||||
expect((graphExplorerInstance.submitToBackend as sinon.SinonSpy).calledWith("g.V()")).toBe(false);
|
||||
});
|
||||
|
||||
it("should submit g.V() as docdb query with proper query", () => {
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[2]
|
||||
).toBe(DOCDB_G_DOT_V_QUERY);
|
||||
});
|
||||
|
||||
it("should submit g.V() as docdb query with proper parameters", () => {
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[0]
|
||||
).toEqual("databaseId");
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[1]
|
||||
).toEqual("collectionId");
|
||||
expect(
|
||||
(graphExplorerInstance.props.documentClientUtility.queryDocuments as sinon.SinonSpy).getCall(0).args[3]
|
||||
).toEqual({ maxItemCount: GraphExplorer.ROOT_LIST_PAGE_SIZE, enableCrossPartitionQuery: true });
|
||||
});
|
||||
|
||||
it("should call backend thrice (user query, fetch outE, then fetch inE)", () => {
|
||||
expect(connectStub.callCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
/* jest-enzyme don't appear to track d3's DOM changes, because they are done outside of React.
|
||||
Here we test that the proper graph updates are passed to d3 via updateGraphData.
|
||||
TODO Testing of the rendering itself from graphData to d3 nodes should be done in D3ForceGraph.
|
||||
*/
|
||||
describe("Render graph results", () => {
|
||||
const node1Id = "vertex1";
|
||||
const node2Id = "vertex2";
|
||||
const edge1Id = "edge1";
|
||||
const prop1Id = "p1Id";
|
||||
const prop1Val1 = "p1v1";
|
||||
const linkLabel = "link1";
|
||||
const label1 = "label1";
|
||||
const label2 = "label2";
|
||||
|
||||
const edge = {
|
||||
id: edge1Id,
|
||||
inV: node2Id,
|
||||
outV: node1Id,
|
||||
label: linkLabel,
|
||||
type: "edge"
|
||||
};
|
||||
|
||||
beforeEach(done => {
|
||||
const backendResponses: BackendResponses = {};
|
||||
// TODO Make this less dependent on spaces, order and quotes
|
||||
backendResponses["g.V()"] = backendResponses[`g.V("${node1Id}","${node2Id}")`] = {
|
||||
response: [
|
||||
{
|
||||
id: node1Id,
|
||||
label: label1,
|
||||
type: "vertex",
|
||||
properties: { prop1Id: [{ id: "id123", value: prop1Val1 }] }
|
||||
},
|
||||
{
|
||||
id: node2Id,
|
||||
label: label2,
|
||||
type: "vertex"
|
||||
}
|
||||
],
|
||||
isLast: false
|
||||
};
|
||||
|
||||
backendResponses[createFetchOutEQuery(node1Id, GraphExplorer.LOAD_PAGE_SIZE + 1)] = {
|
||||
response: [
|
||||
{
|
||||
e: edge,
|
||||
v: {
|
||||
id: node2Id,
|
||||
label: label2,
|
||||
type: "vertex"
|
||||
}
|
||||
}
|
||||
],
|
||||
isLast: false
|
||||
};
|
||||
backendResponses[createFetchInEQuery(node1Id, GraphExplorer.LOAD_PAGE_SIZE)] = { response: [], isLast: true };
|
||||
|
||||
backendResponses[createFetchOutEQuery(node2Id, GraphExplorer.LOAD_PAGE_SIZE + 1)] = {
|
||||
response: [],
|
||||
isLast: false
|
||||
};
|
||||
backendResponses[createFetchInEQuery(node2Id, GraphExplorer.LOAD_PAGE_SIZE + 1)] = {
|
||||
response: [
|
||||
{
|
||||
e: {
|
||||
id: edge1Id,
|
||||
inV: node2Id,
|
||||
outV: node1Id,
|
||||
label: linkLabel,
|
||||
type: "edge"
|
||||
},
|
||||
v: {
|
||||
id: node1Id,
|
||||
label: label1,
|
||||
type: "vertex"
|
||||
}
|
||||
}
|
||||
],
|
||||
isLast: true
|
||||
};
|
||||
|
||||
const docDBResponse: AjaxResponse = { response: [{ id: node1Id }, { id: node2Id }], isLast: false };
|
||||
|
||||
// Data is a graph with two vertices linked to each other
|
||||
bringUpGraphExplorer(docDBResponse, backendResponses, done, false);
|
||||
|
||||
wrapper.find("button.queryButton").simulate("click");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanUpStubsWrapper();
|
||||
});
|
||||
|
||||
// Middle pane and graph
|
||||
it("should display middle pane", () => {
|
||||
expect(isVisible(".maingraphContainer")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not show json results", () => {
|
||||
expect(isVisible(".graphJsonEditor")).toBe(false);
|
||||
});
|
||||
|
||||
it("should render svg root elements", () => {
|
||||
expect(isVisible(".maingraphContainer svg")).toBe(true);
|
||||
expect(isVisible(".maingraphContainer svg g#loadMoreIcon")).toBe(true);
|
||||
expect(isVisible(".maingraphContainer svg marker")).toBe(true);
|
||||
expect(isVisible(".maingraphContainer svg symbol g#triangleRight")).toBe(true);
|
||||
});
|
||||
|
||||
it("should update the graph with proper nodes", () => {
|
||||
let newGraph = (mockGraphRenderer.updateGraph as sinon.SinonSpy).args[0][0];
|
||||
// Hydrate
|
||||
const graphData = new GraphData();
|
||||
Object.assign(graphData, newGraph);
|
||||
|
||||
expect(graphData.ids).toContain(node1Id);
|
||||
expect(graphData.ids).toContain(node2Id);
|
||||
});
|
||||
|
||||
it("should update the graph with proper edges", () => {
|
||||
let newGraph = (mockGraphRenderer.updateGraph as sinon.SinonSpy).args[0][0];
|
||||
// Hydrate
|
||||
const graphData = new GraphData();
|
||||
Object.assign(graphData, newGraph);
|
||||
|
||||
expect(graphData.edges).toEqual([edge]);
|
||||
});
|
||||
|
||||
describe("Expand graph", () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find(".graphExpandCollapseBtn").simulate("click");
|
||||
});
|
||||
|
||||
it("should make left pane disappear", () => {
|
||||
expect(isVisible(".leftPane")).toBe(false);
|
||||
});
|
||||
|
||||
it("should make right pane disappear", () => {
|
||||
expect(isVisible(".rightPane .panelContent")).toBe(false);
|
||||
});
|
||||
|
||||
it("should make middle pane stay", () => {
|
||||
expect(isVisible(".middlePane")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Left pane
|
||||
it("should display left pane", () => {
|
||||
expect(isVisible(".leftPane")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not display Load More (root nodes)", () => {
|
||||
expect(wrapper.exists(".loadMore a")).toBe(false);
|
||||
});
|
||||
|
||||
it("should display list of clickable nodes", () => {
|
||||
const leftPaneLinks = wrapper.find(".leftPane a");
|
||||
expect(leftPaneLinks.length).toBe(2);
|
||||
expect(leftPaneLinks.at(0).text()).toBe(node1Id);
|
||||
expect(leftPaneLinks.at(1).text()).toBe(node2Id);
|
||||
});
|
||||
|
||||
describe("Select root node", () => {
|
||||
let loadNeighborsPageStub: sinon.SinonSpy;
|
||||
|
||||
beforeEach(done => {
|
||||
loadNeighborsPageStub = sinon.stub(graphExplorerInstance, "loadNeighborsPage").callsFake(() => {
|
||||
return Q.resolve();
|
||||
});
|
||||
|
||||
// Find link with node2Id
|
||||
const links = wrapper.find(".leftPane a");
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links.at(i);
|
||||
if (link.text() === node2Id) {
|
||||
link.simulate("click");
|
||||
setTimeout(done, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loadNeighborsPageStub.restore();
|
||||
});
|
||||
|
||||
it("should update node for right pane", () => {
|
||||
expect((wrapper.state("highlightedNode") as GraphHighlightedNodeData).id).toBe(node2Id);
|
||||
expect(wrapper.state("selectedRootId")).toBe(node2Id);
|
||||
});
|
||||
});
|
||||
|
||||
// Right pane
|
||||
it("should display right pane", () => {
|
||||
expect(isVisible(".rightPane")).toBe(true);
|
||||
});
|
||||
|
||||
it("should display right pane expanded", () => {
|
||||
expect(wrapper.state("isPropertiesCollapsed")).toBe(false);
|
||||
});
|
||||
|
||||
describe("Collapsible right pane", () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find(".graphExpandCollapseBtn").simulate("click");
|
||||
});
|
||||
|
||||
it("should make right pane collapse", () => {
|
||||
expect(wrapper.state("isPropertiesCollapsed")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("graph autoviz should be enabled by default", () => {
|
||||
expect(graphExplorerInstance.isGraphAutoVizDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should display RU consumption", () => {
|
||||
// Find link for query stats
|
||||
const links = wrapper.find(".toggleSwitch");
|
||||
let isRUDisplayed = false;
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links.at(i);
|
||||
if (link.text() === GraphExplorer.QUERY_STATS_BUTTON_LABEL) {
|
||||
link.simulate("click");
|
||||
|
||||
const values = wrapper.find(".queryMetricsSummary td");
|
||||
for (let j = 0; j < values.length; j++) {
|
||||
if (Number(values.at(j).text()) === gVRU) {
|
||||
isRUDisplayed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(isRUDisplayed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Handle graph result processing error", () => {
|
||||
let reportToConsole: sinon.SinonSpy;
|
||||
let processGremlinQueryResultsStub: sinon.SinonSpy;
|
||||
let graphExplorerInstance: GraphExplorer;
|
||||
|
||||
beforeEach(done => {
|
||||
const backendResponses: BackendResponses = {};
|
||||
// TODO Make this less dependent on spaces, order and quotes
|
||||
backendResponses["g.V()"] = {
|
||||
response: "invalid response",
|
||||
isLast: true
|
||||
};
|
||||
|
||||
const docDBResponse: AjaxResponse = { response: [], isLast: false };
|
||||
|
||||
// Data is a graph with two vertices linked to each other
|
||||
graphExplorerInstance = bringUpGraphExplorer(docDBResponse, backendResponses, done, false);
|
||||
processGremlinQueryResultsStub = sinon
|
||||
.stub(graphExplorerInstance, "processGremlinQueryResults")
|
||||
.callsFake(() => {
|
||||
done();
|
||||
throw new Error("This is an error");
|
||||
});
|
||||
reportToConsole = sinon.spy(GraphExplorer, "reportToConsole");
|
||||
|
||||
wrapper.find(".loadGraphBtn button").simulate("click");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanUpStubsWrapper();
|
||||
reportToConsole.restore();
|
||||
processGremlinQueryResultsStub.restore();
|
||||
});
|
||||
|
||||
it("should display error", () => {
|
||||
expect(reportToConsole.calledWith(ConsoleDataType.Error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when isGraphAutoVizDisabled setting is true (autoviz disabled)", () => {
|
||||
beforeEach(done => {
|
||||
const backendResponses: BackendResponses = {};
|
||||
backendResponses["g.V()"] = backendResponses['g.V("3")'] = {
|
||||
response: [{ id: "3", type: "vertex" }],
|
||||
isLast: true
|
||||
};
|
||||
const docDBResponse: AjaxResponse = { response: [{ id: "3" }], isLast: false };
|
||||
|
||||
bringUpGraphExplorer(docDBResponse, backendResponses, done, true);
|
||||
graphExplorerInstance.isGraphAutoVizDisabled = true;
|
||||
wrapper.find("button.queryButton").simulate("click");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanUpStubsWrapper();
|
||||
});
|
||||
|
||||
it("should show json results", () => {
|
||||
expect(isVisible(".graphJsonEditor"));
|
||||
});
|
||||
|
||||
it("should not show graph results", () => {
|
||||
expect(!isVisible(".middlePane"));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1997
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx
Normal file
1997
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
||||
import { GraphConfig } from "../../Tabs/GraphTab";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { GraphExplorer, GraphAccessor } from "./GraphExplorer";
|
||||
import DocumentClientUtilityBase from "../../../Common/DocumentClientUtilityBase";
|
||||
|
||||
interface Parameter {
|
||||
onIsNewVertexDisabledChange: (isEnabled: boolean) => void;
|
||||
onGraphAccessorCreated: (instance: GraphAccessor) => void;
|
||||
onIsFilterQueryLoading: (isFilterQueryLoading: boolean) => void;
|
||||
onIsValidQuery: (isValidQuery: boolean) => void;
|
||||
onIsPropertyEditing: (isEditing: boolean) => void;
|
||||
onIsGraphDisplayed: (isDisplayed: boolean) => void;
|
||||
onResetDefaultGraphConfigValues: () => void;
|
||||
|
||||
graphConfigUiData: ViewModels.GraphConfigUiData;
|
||||
graphConfig?: GraphConfig;
|
||||
|
||||
collectionPartitionKeyProperty: string;
|
||||
documentClientUtility: DocumentClientUtilityBase;
|
||||
collectionRid: string;
|
||||
collectionSelfLink: string;
|
||||
graphBackendEndpoint: string;
|
||||
databaseId: string;
|
||||
collectionId: string;
|
||||
masterKey: string;
|
||||
|
||||
onLoadStartKey: number;
|
||||
onLoadStartKeyChange: (newKey: number) => void;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export class GraphExplorerAdapter implements ReactAdapter {
|
||||
public params: Parameter;
|
||||
public parameters = {};
|
||||
public isNewVertexDisabled: boolean;
|
||||
|
||||
public constructor(params: Parameter) {
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
public renderComponent(): JSX.Element {
|
||||
return (
|
||||
<GraphExplorer
|
||||
onIsNewVertexDisabledChange={this.params.onIsNewVertexDisabledChange}
|
||||
onGraphAccessorCreated={this.params.onGraphAccessorCreated}
|
||||
onIsFilterQueryLoadingChange={this.params.onIsFilterQueryLoading}
|
||||
onIsValidQueryChange={this.params.onIsValidQuery}
|
||||
onIsPropertyEditing={this.params.onIsPropertyEditing}
|
||||
onIsGraphDisplayed={this.params.onIsGraphDisplayed}
|
||||
onResetDefaultGraphConfigValues={this.params.onResetDefaultGraphConfigValues}
|
||||
collectionPartitionKeyProperty={this.params.collectionPartitionKeyProperty}
|
||||
documentClientUtility={this.params.documentClientUtility}
|
||||
collectionRid={this.params.collectionRid}
|
||||
collectionSelfLink={this.params.collectionSelfLink}
|
||||
graphBackendEndpoint={this.params.graphBackendEndpoint}
|
||||
databaseId={this.params.databaseId}
|
||||
collectionId={this.params.collectionId}
|
||||
masterKey={this.params.masterKey}
|
||||
onLoadStartKey={this.params.onLoadStartKey}
|
||||
onLoadStartKeyChange={this.params.onLoadStartKeyChange}
|
||||
resourceId={this.params.resourceId}
|
||||
/* TODO Figure out how to make this Knockout-free */
|
||||
graphConfigUiData={this.params.graphConfigUiData}
|
||||
graphConfig={this.params.graphConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
202
src/Explorer/Graph/GraphExplorerComponent/GraphUtil.test.ts
Normal file
202
src/Explorer/Graph/GraphExplorerComponent/GraphUtil.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { GraphUtil } from "./GraphUtil";
|
||||
import { GraphData, GremlinVertex, GremlinEdge } from "./GraphData";
|
||||
import * as sinon from "sinon";
|
||||
import { GraphExplorer } from "./GraphExplorer";
|
||||
window.$ = window.jQuery = require("jquery");
|
||||
|
||||
const OUT_E_MATCHER = "g\\.V\\(.*\\).outE\\(\\).*\\.as\\('e'\\).inV\\(\\)\\.as\\('v'\\)\\.select\\('e', *'v'\\)";
|
||||
const IN_E_MATCHER = "g\\.V\\(.*\\).inE\\(\\).*\\.as\\('e'\\).outV\\(\\)\\.as\\('v'\\)\\.select\\('e', *'v'\\)";
|
||||
|
||||
describe("Process Gremlin vertex", () => {
|
||||
let graphData: GraphData<GremlinVertex, GremlinEdge>;
|
||||
|
||||
beforeEach(() => {
|
||||
graphData = new GraphData();
|
||||
sinon.spy(graphData, "addEdge");
|
||||
});
|
||||
|
||||
it("Should create incoming edge from vertex", () => {
|
||||
const v: GremlinVertex = {
|
||||
id: "id",
|
||||
label: "label",
|
||||
inE: {
|
||||
inEdge: [{ id: "id1", outV: "outV1" }]
|
||||
}
|
||||
};
|
||||
GraphUtil.createEdgesfromNode(v, graphData);
|
||||
const expectedEdge: GremlinEdge = { id: "id1", inV: "id", outV: "outV1", label: "inEdge" };
|
||||
const actualEdge = (<sinon.SinonSpy>graphData.addEdge).getCall(0).args[0];
|
||||
expect(actualEdge).toEqual(expectedEdge);
|
||||
});
|
||||
it("Should create outgoing edge from vertex", () => {
|
||||
const v: GremlinVertex = {
|
||||
id: "id",
|
||||
label: "label",
|
||||
outE: {
|
||||
outEdge: [{ id: "id2", inV: "inV2" }]
|
||||
}
|
||||
};
|
||||
GraphUtil.createEdgesfromNode(v, graphData);
|
||||
const expectedEdge: GremlinEdge = { id: "id2", inV: "inV2", outV: "id", label: "outEdge" };
|
||||
const actualEdge = (<sinon.SinonSpy>graphData.addEdge).getCall(0).args[0];
|
||||
expect(actualEdge).toEqual(expectedEdge);
|
||||
});
|
||||
|
||||
it("Should remember new nodes", () => {
|
||||
const v: GremlinVertex = {
|
||||
id: "id",
|
||||
label: "label",
|
||||
inE: {
|
||||
inEdge: [{ id: "id1", outV: "outV1" }]
|
||||
},
|
||||
outE: {
|
||||
outEdge: [
|
||||
{ id: "id2", inV: "inV2" },
|
||||
{ id: "id3", inV: "inV3" }
|
||||
]
|
||||
}
|
||||
};
|
||||
const newNodes = {};
|
||||
GraphUtil.createEdgesfromNode(v, graphData, newNodes);
|
||||
const keys = Object.keys(newNodes);
|
||||
expect(keys.length).toEqual(3);
|
||||
expect(keys.indexOf("outV1")).toBeGreaterThan(-1);
|
||||
expect(keys.indexOf("inV2")).toBeGreaterThan(-1);
|
||||
expect(keys.indexOf("inV3")).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLimitedArrayString()", () => {
|
||||
const expectedEmptyResult = { result: "", consumedCount: 0 };
|
||||
it("should handle null array", () => {
|
||||
expect(GraphUtil.getLimitedArrayString(null, 10)).toEqual(expectedEmptyResult);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
expect(GraphUtil.getLimitedArrayString([], 10)).toEqual(expectedEmptyResult);
|
||||
});
|
||||
|
||||
it("should handle 1st element exceeding max limit", () => {
|
||||
expect(GraphUtil.getLimitedArrayString(["123", "1", "2"], 4)).toEqual(expectedEmptyResult);
|
||||
});
|
||||
|
||||
it("should handle nth element makes it exceed max limit", () => {
|
||||
const expected = {
|
||||
result: "'1','2'",
|
||||
consumedCount: 2
|
||||
};
|
||||
expect(GraphUtil.getLimitedArrayString(["1", "2", "12345", "4", "5"], 10)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should consume all elements if limit never exceeding limit", () => {
|
||||
const expected = {
|
||||
result: "'1','22','3'",
|
||||
consumedCount: 3
|
||||
};
|
||||
expect(GraphUtil.getLimitedArrayString(["1", "22", "3"], 12)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchEdgeVertexPairs()", () => {
|
||||
const pkid = "'id'";
|
||||
const max = GraphExplorer.WITHOUT_STEP_ARGS_MAX_CHARS;
|
||||
const startIndex = 0;
|
||||
const pageSize = max - 10; // stay below the limit
|
||||
|
||||
it("should perform outE() query", () => {
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, [], startIndex, pageSize, max)).toMatch(
|
||||
new RegExp(OUT_E_MATCHER, "g")
|
||||
);
|
||||
});
|
||||
|
||||
it("should perform inE() query", () => {
|
||||
expect(GraphUtil.createFetchEdgePairQuery(false, pkid, [], startIndex, pageSize, max)).toMatch(
|
||||
new RegExp(IN_E_MATCHER, "g")
|
||||
);
|
||||
});
|
||||
|
||||
it("should contain .has(id, without()) step which contains excludedIds", () => {
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, ["id1", "id2"], startIndex, pageSize, max)).toMatch(
|
||||
/\.has\(id, *without\('id1', *'id2'\)\)/g
|
||||
);
|
||||
});
|
||||
|
||||
it("should not contain .without() when excludedIds is empty step", () => {
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, [], startIndex, pageSize, max)).toMatch(/^((?!without).)*$/g);
|
||||
});
|
||||
|
||||
it("should fetch with .limit() and not .range() step if excludedIds not too big", () => {
|
||||
const regex = new RegExp(`\\.limit\\(${pageSize}\\)`, "g");
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, ["id1", "id2"], startIndex, pageSize, max)).toMatch(regex);
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, ["id1", "id2"], startIndex, pageSize, max)).toMatch(
|
||||
/^((?!range).)*$/g
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch with .range() and not .limit() step if excludedIds is too big", () => {
|
||||
const excludedIds = ["id1", "id2", "ids3"];
|
||||
const smallLimit = 8; // just enough to consume only id1
|
||||
const start = 12;
|
||||
const size = 15;
|
||||
const expectedStart = 12 - 1; // Request to start from 12, but exclude id1 (1 element), so we start from 11
|
||||
const regex = new RegExp(`\\.range\\(${expectedStart}, *${expectedStart + size}\\)`, "g");
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, excludedIds, start, size, smallLimit)).toMatch(regex);
|
||||
expect(GraphUtil.createFetchEdgePairQuery(true, pkid, excludedIds, start, size, smallLimit)).toMatch(
|
||||
/^((?!limit).)*$/g
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trim graph when loading new edges", () => {
|
||||
const grandpa: GremlinVertex = { id: "grandpa", label: "label" };
|
||||
const root: GremlinVertex = { id: "root", label: "label", _ancestorsId: [grandpa.id] };
|
||||
const johndoe: GremlinVertex = { id: "johndoe", label: "label" };
|
||||
let graphData: GraphData<GremlinVertex, GremlinEdge>;
|
||||
|
||||
beforeEach(() => {
|
||||
graphData = new GraphData();
|
||||
graphData.addVertex(grandpa);
|
||||
graphData.addVertex(root);
|
||||
graphData.addVertex(johndoe);
|
||||
GraphUtil.trimGraph(root, graphData);
|
||||
});
|
||||
|
||||
it("should not remove current root", () => {
|
||||
expect(graphData.hasVertexId(root.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not remove ancestors of current root", () => {
|
||||
expect(graphData.hasVertexId(grandpa.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("should make all ancestors fixed position", () => {
|
||||
expect(root._isFixedPosition).toBe(true);
|
||||
expect(grandpa._isFixedPosition).toBe(true);
|
||||
});
|
||||
|
||||
it("should remove any other vertices", () => {
|
||||
expect(graphData.hasVertexId(johndoe.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add root child to graph", () => {
|
||||
const root: GremlinVertex = { id: "root", label: "label" };
|
||||
const kiddo: GremlinVertex = { id: "kiddo", label: "label" };
|
||||
let graphData: GraphData<GremlinVertex, GremlinEdge>;
|
||||
|
||||
beforeEach(() => {
|
||||
graphData = new GraphData();
|
||||
graphData.addVertex(root);
|
||||
graphData.addVertex(kiddo);
|
||||
|
||||
GraphUtil.addRootChildToGraph(root, kiddo, graphData);
|
||||
});
|
||||
|
||||
it("should add child to graph", () => {
|
||||
expect(graphData.hasVertexId(kiddo.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("should add root to child ancestors", () => {
|
||||
expect(!!kiddo._ancestorsId && kiddo._ancestorsId.indexOf(root.id) > -1).toBe(true);
|
||||
});
|
||||
});
|
||||
185
src/Explorer/Graph/GraphExplorerComponent/GraphUtil.ts
Normal file
185
src/Explorer/Graph/GraphExplorerComponent/GraphUtil.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NeighborVertexBasicInfo } from "./GraphExplorer";
|
||||
import * as GraphData from "./GraphData";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
interface JoinArrayMaxCharOutput {
|
||||
result: string; // string output
|
||||
consumedCount: number; // Number of items consumed
|
||||
}
|
||||
|
||||
export class GraphUtil {
|
||||
public static getNeighborTitle(neighbor: NeighborVertexBasicInfo): string {
|
||||
return `edge id: ${neighbor.edgeId}, vertex id: ${neighbor.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all edges from this node
|
||||
* @param vertex
|
||||
* @param graphData
|
||||
* @param newNodes (optional) object describing new nodes encountered
|
||||
*/
|
||||
public static createEdgesfromNode(
|
||||
vertex: GraphData.GremlinVertex,
|
||||
graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>,
|
||||
newNodes?: { [id: string]: boolean }
|
||||
): void {
|
||||
if (vertex.hasOwnProperty("outE")) {
|
||||
let outE = vertex.outE;
|
||||
for (var label in outE) {
|
||||
$.each(outE[label], (index: number, edge: any) => {
|
||||
// We create our own edge. No need to fetch
|
||||
let e = {
|
||||
id: edge.id,
|
||||
label: label,
|
||||
inV: edge.inV,
|
||||
outV: vertex.id
|
||||
};
|
||||
|
||||
graphData.addEdge(e);
|
||||
if (newNodes) {
|
||||
newNodes[edge.inV] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (vertex.hasOwnProperty("inE")) {
|
||||
let inE = vertex.inE;
|
||||
for (var label in inE) {
|
||||
$.each(inE[label], (index: number, edge: any) => {
|
||||
// We create our own edge. No need to fetch
|
||||
let e = {
|
||||
id: edge.id,
|
||||
label: label,
|
||||
inV: vertex.id,
|
||||
outV: edge.outV
|
||||
};
|
||||
|
||||
graphData.addEdge(e);
|
||||
if (newNodes) {
|
||||
newNodes[edge.outV] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* From ['id1', 'id2', 'idn'] build the following string "'id1','id2','idn'".
|
||||
* The string length cannot exceed maxSize.
|
||||
* @param array
|
||||
* @param maxSize
|
||||
* @return
|
||||
*/
|
||||
public static getLimitedArrayString(array: string[], maxSize: number): JoinArrayMaxCharOutput {
|
||||
if (!array || array.length === 0 || array[0].length + 2 > maxSize) {
|
||||
return { result: "", consumedCount: 0 };
|
||||
}
|
||||
|
||||
const end = array.length - 1;
|
||||
let output = `'${array[0]}'`;
|
||||
let i = 0;
|
||||
for (; i < end; i++) {
|
||||
const candidate = `${output},'${array[i + 1]}'`;
|
||||
if (candidate.length <= maxSize) {
|
||||
output = candidate;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: output,
|
||||
consumedCount: i + 1
|
||||
};
|
||||
}
|
||||
|
||||
public static createFetchEdgePairQuery(
|
||||
outE: boolean,
|
||||
pkid: string,
|
||||
excludedEdgeIds: string[],
|
||||
startIndex: number,
|
||||
pageSize: number,
|
||||
withoutStepArgMaxLenght: number
|
||||
): string {
|
||||
let gremlinQuery: string;
|
||||
if (excludedEdgeIds.length > 0) {
|
||||
// build a string up to max char
|
||||
const joined = GraphUtil.getLimitedArrayString(excludedEdgeIds, withoutStepArgMaxLenght);
|
||||
const hasWithoutStep = !!joined.result ? `.has(id, without(${joined.result}))` : "";
|
||||
|
||||
if (joined.consumedCount === excludedEdgeIds.length) {
|
||||
gremlinQuery = `g.V(${pkid}).${outE ? "outE" : "inE"}()${hasWithoutStep}.limit(${pageSize}).as('e').${
|
||||
outE ? "inV" : "outV"
|
||||
}().as('v').select('e', 'v')`;
|
||||
} else {
|
||||
const start = startIndex - joined.consumedCount;
|
||||
gremlinQuery = `g.V(${pkid}).${outE ? "outE" : "inE"}()${hasWithoutStep}.range(${start},${start +
|
||||
pageSize}).as('e').${outE ? "inV" : "outV"}().as('v').select('e', 'v')`;
|
||||
}
|
||||
} else {
|
||||
gremlinQuery = `g.V(${pkid}).${outE ? "outE" : "inE"}().limit(${pageSize}).as('e').${
|
||||
outE ? "inV" : "outV"
|
||||
}().as('v').select('e', 'v')`;
|
||||
}
|
||||
return gremlinQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim graph
|
||||
*/
|
||||
public static trimGraph(
|
||||
currentRoot: GraphData.GremlinVertex,
|
||||
graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>
|
||||
) {
|
||||
const importantNodes = [currentRoot.id].concat(currentRoot._ancestorsId);
|
||||
graphData.unloadAllVertices(importantNodes);
|
||||
|
||||
// Keep only ancestors node in fixed position
|
||||
$.each(graphData.ids, (index: number, id: string) => {
|
||||
graphData.getVertexById(id)._isFixedPosition = importantNodes.indexOf(id) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
public static addRootChildToGraph(
|
||||
root: GraphData.GremlinVertex,
|
||||
child: GraphData.GremlinVertex,
|
||||
graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>
|
||||
) {
|
||||
child._ancestorsId = (root._ancestorsId || []).concat([root.id]);
|
||||
graphData.addVertex(child);
|
||||
GraphUtil.createEdgesfromNode(child, graphData);
|
||||
graphData.addNeighborInfo(child);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Perform minimal substitution to prevent breaking gremlin query and allow \"" for now.
|
||||
* @param value
|
||||
*/
|
||||
public static escapeDoubleQuotes(value: string): string {
|
||||
return value == null ? value : value.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Surround with double-quotes if val is a string.
|
||||
* @param val
|
||||
*/
|
||||
public static getQuotedPropValue(ip: ViewModels.InputPropertyValue): string {
|
||||
switch (ip.type) {
|
||||
case "number":
|
||||
case "boolean":
|
||||
return `${ip.value}`;
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
return `"${GraphUtil.escapeDoubleQuotes(ip.value as string)}"`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO Perform minimal substitution to prevent breaking gremlin query and allow \' for now.
|
||||
* @param value
|
||||
*/
|
||||
public static escapeSingleQuotes(value: string): string {
|
||||
return value == null ? value : value.replace(/'/g, "\\'");
|
||||
}
|
||||
}
|
||||
172
src/Explorer/Graph/GraphExplorerComponent/GraphVizComponent.tsx
Normal file
172
src/Explorer/Graph/GraphExplorerComponent/GraphVizComponent.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as React from "react";
|
||||
import { D3ForceGraph, D3ForceGraphParameters } from "./D3ForceGraph";
|
||||
|
||||
export interface GraphVizComponentProps {
|
||||
forceGraphParams: D3ForceGraphParameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both React and D3 are modifying the DOM and therefore should not share control.
|
||||
* The approach taken here is to block React updates and let d3 take control of the dom and do its thing.
|
||||
*/
|
||||
export class GraphVizComponent extends React.Component<GraphVizComponentProps> {
|
||||
private forceGraph: D3ForceGraph;
|
||||
private rootNode: Element;
|
||||
|
||||
public constructor(props: GraphVizComponentProps) {
|
||||
super(props);
|
||||
this.forceGraph = new D3ForceGraph(this.props.forceGraphParams);
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.forceGraph.init(this.rootNode);
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(): boolean {
|
||||
// Prevents component re-rendering
|
||||
return false;
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.forceGraph.destroy();
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<svg id="maingraph" ref={(elt: Element) => this.setRef(elt)}>
|
||||
<title>Main Graph</title>
|
||||
<defs>
|
||||
<g id="loadMoreIcon">
|
||||
{/* svg load more icon inlined as-is here: remove the style="fill:#374649;" so we can override it */}
|
||||
<svg
|
||||
role="img"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="30px"
|
||||
height="16px"
|
||||
viewBox="0 0 30 16"
|
||||
style={
|
||||
{
|
||||
/*enableBackground: 'new 0 0 30 16'*/
|
||||
}
|
||||
}
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<g style={{ opacity: 1 }}>
|
||||
<g>
|
||||
<g style={{ opacity: 0.4 }}>
|
||||
<ellipse
|
||||
transform="matrix(0.9903 -0.1393 0.1393 0.9903 -1.4513 2.1015)"
|
||||
cx="14.3"
|
||||
cy="11.4"
|
||||
rx="4.1"
|
||||
ry="4.1"
|
||||
/>
|
||||
</g>
|
||||
<g style={{ opacity: 0.4 }}>
|
||||
<ellipse
|
||||
transform="matrix(0.3256 -0.9455 0.9455 0.3256 11.2761 30.3703)"
|
||||
cx="26.9"
|
||||
cy="7.3"
|
||||
rx="3.1"
|
||||
ry="3.1"
|
||||
/>
|
||||
</g>
|
||||
<line
|
||||
style={{ opacity: 0.5, fill: "none", stroke: "#BABCBE", strokeMiterlimit: 10 }}
|
||||
x1="14.4"
|
||||
y1="7.3"
|
||||
x2="14.6"
|
||||
y2="2.5"
|
||||
/>
|
||||
<line
|
||||
style={{ opacity: 0.5, fill: "none", stroke: "#BABCBE", strokeMiterlimit: 10 }}
|
||||
x1="17.6"
|
||||
y1="1.1"
|
||||
x2="24.5"
|
||||
y2="5.4"
|
||||
/>
|
||||
<g style={{ opacity: 0.4 }}>
|
||||
<ellipse
|
||||
transform="matrix(0.932 -0.3625 0.3625 0.932 -0.9642 1.3456)"
|
||||
cx="3.1"
|
||||
cy="3.2"
|
||||
rx="3.1"
|
||||
ry="3.1"
|
||||
/>
|
||||
</g>
|
||||
<line
|
||||
style={{ opacity: 0.5, fill: "none", stroke: "#BABCBE", strokeMiterlimit: 10 }}
|
||||
x1="10.6"
|
||||
y1="1.1"
|
||||
x2="6.1"
|
||||
y2="2.5"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
{/* <!-- End of load more icon --> */}
|
||||
|
||||
{/* <!-- Make whole area clickable instead of the shape --> */}
|
||||
<rect x="0px" y="0px" width="32px" height="17px" style={{ fillOpacity: 0 }} />
|
||||
</g>
|
||||
|
||||
<marker
|
||||
id={`${this.forceGraph.getArrowHeadSymbolId()}-marker`}
|
||||
viewBox="0 -5 10 10"
|
||||
refX="8"
|
||||
refY="0"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
orient="auto"
|
||||
markerUnits="userSpaceOnUse"
|
||||
fill="#aaa"
|
||||
stroke="#aaa"
|
||||
fillOpacity="1"
|
||||
strokeOpacity="1"
|
||||
>
|
||||
<path d="M0,-4L10,0L0,4" style={{ stroke: "none" }} />
|
||||
</marker>
|
||||
</defs>
|
||||
<symbol>
|
||||
<g id="triangleRight">
|
||||
<svg
|
||||
role="img"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 6 10"
|
||||
style={
|
||||
{
|
||||
/*enableBackground: 'new 0 0 6 10'*/
|
||||
}
|
||||
}
|
||||
xmlSpace="preserve"
|
||||
width="20px"
|
||||
height="20px"
|
||||
>
|
||||
<polygon points="0.5,10 0.5,0 5.2,5 " />
|
||||
</svg>
|
||||
{/*<!-- Make whole area clickable instead of the shape -->*/}
|
||||
<rect x="0px" y="0px" width="15px" height="20px" style={{ fillOpacity: 0 }} />
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol>
|
||||
<g id={`${this.forceGraph.getArrowHeadSymbolId()}-nonMarker`}>
|
||||
<polygon points="0,0 -8,-3 -8,3 " />
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
private setRef(element: Element): void {
|
||||
this.rootNode = element;
|
||||
}
|
||||
}
|
||||
266
src/Explorer/Graph/GraphExplorerComponent/GremlinClient.test.ts
Normal file
266
src/Explorer/Graph/GraphExplorerComponent/GremlinClient.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as sinon from "sinon";
|
||||
import { GremlinClient, GremlinClientParameters } from "./GremlinClient";
|
||||
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
|
||||
import { Logger } from "../../../Common/Logger";
|
||||
|
||||
describe("Gremlin Client", () => {
|
||||
const emptyParams: GremlinClientParameters = {
|
||||
endpoint: null,
|
||||
collectionId: null,
|
||||
databaseId: null,
|
||||
masterKey: null,
|
||||
maxResultSize: 10000
|
||||
};
|
||||
|
||||
it("should use databaseId, collectionId and masterKey to authenticate", () => {
|
||||
const collectionId = "collectionId";
|
||||
const databaseId = "databaseId";
|
||||
const masterKey = "masterKey";
|
||||
const gremlinClient = new GremlinClient();
|
||||
|
||||
gremlinClient.initialize({
|
||||
endpoint: null,
|
||||
collectionId,
|
||||
databaseId,
|
||||
masterKey,
|
||||
maxResultSize: 0
|
||||
});
|
||||
|
||||
// User must includes these values
|
||||
expect(gremlinClient.client.params.user.indexOf(collectionId)).not.toBe(-1);
|
||||
expect(gremlinClient.client.params.user.indexOf(databaseId)).not.toBe(-1);
|
||||
expect(gremlinClient.client.params.password).toEqual(masterKey);
|
||||
});
|
||||
|
||||
it("should aggregate RU charges across multiple responses", done => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const ru1 = 1;
|
||||
const ru2 = 2;
|
||||
const ru3 = 3;
|
||||
const requestId = "id";
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => requestId);
|
||||
gremlinClient
|
||||
.execute("fake query")
|
||||
.then(result => expect(result.totalRequestCharge).toBe(ru1 + ru2 + ru3))
|
||||
.finally(done);
|
||||
|
||||
gremlinClient.client.params.progressCallback({
|
||||
data: ["data1"],
|
||||
requestCharge: ru1,
|
||||
requestId: requestId
|
||||
});
|
||||
gremlinClient.client.params.progressCallback({
|
||||
data: ["data2"],
|
||||
requestCharge: ru2,
|
||||
requestId: requestId
|
||||
});
|
||||
gremlinClient.client.params.successCallback({
|
||||
data: ["data3"],
|
||||
requestCharge: ru3,
|
||||
requestId: requestId
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep track of pending requests", () => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const fakeRequestIds = ["id1", "id2", "id3"];
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => fakeRequestIds.pop());
|
||||
gremlinClient.execute("fake query");
|
||||
gremlinClient.execute("fake query");
|
||||
gremlinClient.execute("fake query");
|
||||
expect(gremlinClient.pendingResults.size()).toBe(3);
|
||||
});
|
||||
|
||||
it("should clean up pending request ids after success", async () => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const ru1 = 1;
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => {
|
||||
const requestId = "id";
|
||||
setTimeout(() => {
|
||||
gremlinClient.client.params.successCallback({
|
||||
data: ["data1"],
|
||||
requestCharge: ru1,
|
||||
requestId: requestId
|
||||
});
|
||||
}, 0);
|
||||
return requestId;
|
||||
});
|
||||
await gremlinClient.execute("fake query");
|
||||
expect(gremlinClient.pendingResults.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("should log and display error out on unknown requestId", () => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const logConsoleSpy = sinon.spy(NotificationConsoleUtils, "logConsoleMessage");
|
||||
const logErrorSpy = sinon.spy(Logger, "logError");
|
||||
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => "requestId");
|
||||
gremlinClient.execute("fake query");
|
||||
gremlinClient.client.params.successCallback({
|
||||
data: ["data1"],
|
||||
requestCharge: 1,
|
||||
requestId: "unknownId"
|
||||
});
|
||||
|
||||
expect(logConsoleSpy.called).toBe(true);
|
||||
expect(logErrorSpy.called).toBe(true);
|
||||
|
||||
logConsoleSpy.restore();
|
||||
logErrorSpy.restore();
|
||||
});
|
||||
|
||||
it("should not display RU if null or undefined", () => {
|
||||
const emptyResult = "";
|
||||
expect(GremlinClient.getRequestChargeString(null)).toEqual(emptyResult);
|
||||
expect(GremlinClient.getRequestChargeString(undefined)).toEqual(emptyResult);
|
||||
expect(GremlinClient.getRequestChargeString(123)).not.toEqual(emptyResult);
|
||||
expect(GremlinClient.getRequestChargeString("123")).not.toEqual(emptyResult);
|
||||
});
|
||||
|
||||
it("should not aggregate RU if not a number and reset totalRequestCharge to undefined", done => {
|
||||
const logConsoleSpy = sinon.spy(NotificationConsoleUtils, "logConsoleMessage");
|
||||
const logErrorSpy = sinon.spy(Logger, "logError");
|
||||
|
||||
const gremlinClient = new GremlinClient();
|
||||
const ru1 = 123;
|
||||
const ru2 = "should be a number";
|
||||
const requestId = "id";
|
||||
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => requestId);
|
||||
gremlinClient
|
||||
.execute("fake query")
|
||||
.then(
|
||||
result => {
|
||||
try {
|
||||
expect(result.totalRequestCharge).toBe(undefined);
|
||||
expect(logConsoleSpy.called).toBe(true);
|
||||
expect(logErrorSpy.called).toBe(true);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
},
|
||||
error => done.fail(error)
|
||||
)
|
||||
.finally(() => {
|
||||
logConsoleSpy.restore();
|
||||
logErrorSpy.restore();
|
||||
});
|
||||
|
||||
gremlinClient.client.params.progressCallback({
|
||||
data: ["data1"],
|
||||
requestCharge: ru1,
|
||||
requestId: requestId
|
||||
});
|
||||
gremlinClient.client.params.successCallback({
|
||||
data: ["data2"],
|
||||
requestCharge: ru2 as any,
|
||||
requestId: requestId
|
||||
});
|
||||
});
|
||||
|
||||
it("should not aggregate RU if undefined and reset totalRequestCharge to undefined", done => {
|
||||
const logConsoleSpy = sinon.spy(NotificationConsoleUtils, "logConsoleMessage");
|
||||
const logErrorSpy = sinon.spy(Logger, "logError");
|
||||
|
||||
const gremlinClient = new GremlinClient();
|
||||
const ru1 = 123;
|
||||
const ru2: number = undefined;
|
||||
const requestId = "id";
|
||||
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => requestId);
|
||||
gremlinClient
|
||||
.execute("fake query")
|
||||
.then(
|
||||
result => {
|
||||
try {
|
||||
expect(result.totalRequestCharge).toBe(undefined);
|
||||
expect(logConsoleSpy.called).toBe(true);
|
||||
expect(logErrorSpy.called).toBe(true);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
},
|
||||
error => done.fail(error)
|
||||
)
|
||||
.finally(() => {
|
||||
logConsoleSpy.restore();
|
||||
logErrorSpy.restore();
|
||||
});
|
||||
|
||||
gremlinClient.client.params.progressCallback({
|
||||
data: ["data1"],
|
||||
requestCharge: ru1,
|
||||
requestId: requestId
|
||||
});
|
||||
gremlinClient.client.params.successCallback({
|
||||
data: ["data2"],
|
||||
requestCharge: ru2,
|
||||
requestId: requestId
|
||||
});
|
||||
});
|
||||
|
||||
it("should track RUs even on failure", done => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const requestId = "id";
|
||||
const RU = 1234;
|
||||
const error = "Some error";
|
||||
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => requestId);
|
||||
const abortPendingRequestSpy = sinon.spy(gremlinClient, "abortPendingRequest");
|
||||
gremlinClient.execute("fake query").then(
|
||||
result => done.fail(`Unexpectedly succeeded with ${result}`),
|
||||
error => {
|
||||
try {
|
||||
expect(abortPendingRequestSpy.calledWith(requestId, error, RU)).toBe(true);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
gremlinClient.client.params.failureCallback(
|
||||
{
|
||||
data: null,
|
||||
requestCharge: RU,
|
||||
requestId: requestId
|
||||
},
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it("should abort all pending requests if requestId from failure response", done => {
|
||||
const gremlinClient = new GremlinClient();
|
||||
const requestId = "id";
|
||||
const error = "Some error";
|
||||
|
||||
gremlinClient.initialize(emptyParams);
|
||||
sinon.stub(gremlinClient.client, "executeGremlinQuery").callsFake((query: string): string => requestId);
|
||||
gremlinClient.execute("fake query").finally(() => {
|
||||
try {
|
||||
expect(gremlinClient.pendingResults.size()).toBe(0);
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
});
|
||||
|
||||
gremlinClient.client.params.failureCallback(
|
||||
{
|
||||
data: null,
|
||||
requestCharge: undefined,
|
||||
requestId: undefined
|
||||
},
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
199
src/Explorer/Graph/GraphExplorerComponent/GremlinClient.ts
Normal file
199
src/Explorer/Graph/GraphExplorerComponent/GremlinClient.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Wrapper around GremlinSimpleClient using Q promises and tailored to cosmosdb authentication
|
||||
*/
|
||||
|
||||
import * as Q from "q";
|
||||
import { GremlinSimpleClient, Result } from "./GremlinSimpleClient";
|
||||
import { NotificationConsoleUtils } from "../../../Utils/NotificationConsoleUtils";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { HashMap } from "../../../Common/HashMap";
|
||||
import { Logger } from "../../../Common/Logger";
|
||||
|
||||
export interface GremlinClientParameters {
|
||||
endpoint: string;
|
||||
databaseId: string;
|
||||
collectionId: string;
|
||||
masterKey: string;
|
||||
maxResultSize: number;
|
||||
}
|
||||
|
||||
export interface GremlinRequestResult {
|
||||
data: any[];
|
||||
totalRequestCharge?: number;
|
||||
isIncomplete: boolean;
|
||||
}
|
||||
|
||||
interface PendingResultData {
|
||||
result: GremlinRequestResult;
|
||||
deferred: Q.Deferred<GremlinRequestResult>;
|
||||
timeoutId: number;
|
||||
}
|
||||
|
||||
export class GremlinClient {
|
||||
public client: GremlinSimpleClient;
|
||||
public pendingResults: HashMap<PendingResultData>; // public for testing purposes
|
||||
private maxResultSize: number;
|
||||
private static readonly PENDING_REQUEST_TIMEOUT_MS = 6 /* minutes */ * 60 /* seconds */ * 1000 /* ms */;
|
||||
private static readonly TIMEOUT_ERROR_MSG = `Pending request timed out (${GremlinClient.PENDING_REQUEST_TIMEOUT_MS} ms)`;
|
||||
private static readonly LOG_AREA = "GremlinClient";
|
||||
|
||||
public initialize(params: GremlinClientParameters) {
|
||||
this.destroy();
|
||||
this.pendingResults = new HashMap();
|
||||
this.maxResultSize = params.maxResultSize;
|
||||
|
||||
this.client = new GremlinSimpleClient({
|
||||
endpoint: params.endpoint,
|
||||
user: `/dbs/${params.databaseId}/colls/${params.collectionId}`,
|
||||
password: params.masterKey,
|
||||
successCallback: (result: Result) => {
|
||||
this.storePendingResult(result);
|
||||
this.flushResult(result.requestId);
|
||||
},
|
||||
progressCallback: (result: Result) => {
|
||||
// Only for informational purposes, since this client accumulates stores all results
|
||||
const isIncomplete = this.storePendingResult(result);
|
||||
if (isIncomplete) {
|
||||
this.flushResult(result.requestId);
|
||||
}
|
||||
},
|
||||
failureCallback: (result: Result, error: string) => {
|
||||
if (typeof error !== "string") {
|
||||
error = JSON.stringify(error);
|
||||
}
|
||||
|
||||
const requestId = result.requestId;
|
||||
|
||||
if (!requestId || !this.pendingResults.has(requestId)) {
|
||||
const msg = `Error: ${error}, unknown requestId:${requestId} ${GremlinClient.getRequestChargeString(
|
||||
result.requestCharge
|
||||
)}`;
|
||||
GremlinClient.reportError(msg);
|
||||
|
||||
// Fail all pending requests if no request id (fatal)
|
||||
if (!requestId) {
|
||||
this.pendingResults.keys().forEach((reqId: string) => {
|
||||
this.abortPendingRequest(reqId, error, null);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.abortPendingRequest(requestId, error, result.requestCharge);
|
||||
}
|
||||
},
|
||||
infoCallback: (msg: string) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public execute(query: string): Q.Promise<GremlinRequestResult> {
|
||||
const deferred = Q.defer<GremlinRequestResult>();
|
||||
const requestId = this.client.executeGremlinQuery(query);
|
||||
this.pendingResults.set(requestId, {
|
||||
result: {
|
||||
data: [] as any[],
|
||||
isIncomplete: false
|
||||
},
|
||||
deferred: deferred,
|
||||
timeoutId: window.setTimeout(
|
||||
() => this.abortPendingRequest(requestId, GremlinClient.TIMEOUT_ERROR_MSG, null),
|
||||
GremlinClient.PENDING_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally display RU if defined
|
||||
* @param requestCharge
|
||||
* @return request charge or empty string
|
||||
*/
|
||||
public static getRequestChargeString(requestCharge: string | number): string {
|
||||
return requestCharge == undefined || requestCharge == null ? "" : `(${requestCharge} RUs)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public for testing purposes
|
||||
* @param requestId
|
||||
* @param error
|
||||
* @param requestCharge
|
||||
*/
|
||||
public abortPendingRequest(requestId: string, error: string, requestCharge: number) {
|
||||
clearTimeout(this.pendingResults.get(requestId).timeoutId);
|
||||
const deferred = this.pendingResults.get(requestId).deferred;
|
||||
deferred.reject(error);
|
||||
this.pendingResults.delete(requestId);
|
||||
|
||||
GremlinClient.reportError(
|
||||
`Aborting pending request ${requestId}. Error:${error} ${GremlinClient.getRequestChargeString(requestCharge)}`
|
||||
);
|
||||
}
|
||||
|
||||
private flushResult(requestId: string) {
|
||||
if (!this.pendingResults.has(requestId)) {
|
||||
const msg = `Unknown requestId:${requestId}`;
|
||||
GremlinClient.reportError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingResult = this.pendingResults.get(requestId);
|
||||
clearTimeout(pendingResult.timeoutId);
|
||||
pendingResult.deferred.resolve(pendingResult.result);
|
||||
this.pendingResults.delete(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge with existing results.
|
||||
* Clip results if necessary to keep results size under max
|
||||
* @param result
|
||||
* @return true if pending results reached max
|
||||
*/
|
||||
private storePendingResult(result: Result): boolean {
|
||||
if (!this.pendingResults.has(result.requestId)) {
|
||||
const msg = `Dropping result for unknown requestId:${result.requestId}`;
|
||||
GremlinClient.reportError(msg);
|
||||
return false;
|
||||
}
|
||||
const pendingResults = this.pendingResults.get(result.requestId).result;
|
||||
const currentSize = pendingResults.data.length;
|
||||
let resultsToAdd = Array.isArray(result.data) ? result.data : [result.data];
|
||||
|
||||
if (currentSize + result.data.length > this.maxResultSize) {
|
||||
const sliceSize = currentSize > this.maxResultSize ? 0 : this.maxResultSize - currentSize;
|
||||
// Clip results to fit under max
|
||||
pendingResults.isIncomplete = true;
|
||||
resultsToAdd = result.data.slice(0, sliceSize);
|
||||
}
|
||||
|
||||
pendingResults.data = <[any]>pendingResults.data.concat(resultsToAdd);
|
||||
|
||||
// Make sure we aggregate two numbers, but continue without it if not.
|
||||
if (result.requestCharge === undefined || typeof result.requestCharge !== "number") {
|
||||
// Clear totalRequestCharge, even if it was a valid number as the total might be incomplete therefore incorrect
|
||||
pendingResults.totalRequestCharge = undefined;
|
||||
GremlinClient.reportError(
|
||||
`Unable to perform RU aggregation calculation with non numbers. Result request charge: ${result.requestCharge}. RequestId: ${result.requestId}`
|
||||
);
|
||||
} else {
|
||||
if (pendingResults.totalRequestCharge === undefined) {
|
||||
pendingResults.totalRequestCharge = 0;
|
||||
}
|
||||
pendingResults.totalRequestCharge += result.requestCharge;
|
||||
}
|
||||
return pendingResults.isIncomplete;
|
||||
}
|
||||
|
||||
private static reportError(msg: string): void {
|
||||
console.error(msg);
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg);
|
||||
Logger.logError(msg, GremlinClient.LOG_AREA);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
// TODO Should be able to remove this in next version of nodejs
|
||||
// https://github.com/nodejs/node/commit/932be0164fb3ed869ae3ddfff391721d2fd8e1af
|
||||
// (<any>global).TextEncoder = require('util').TextEncoder
|
||||
// (<any>global).TextDecoder = require('util').TextDecoder
|
||||
|
||||
import * as sinon from "sinon";
|
||||
import {
|
||||
GremlinSimpleClient,
|
||||
GremlinSimpleClientParameters,
|
||||
Result,
|
||||
GremlinRequestMessage,
|
||||
GremlinResponseMessage
|
||||
} from "./GremlinSimpleClient";
|
||||
|
||||
describe("Gremlin Simple Client", () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
let fakeSocket: any;
|
||||
|
||||
const createParams = (): GremlinSimpleClientParameters => {
|
||||
return {
|
||||
endpoint: "endpoint",
|
||||
user: "user",
|
||||
password: "password",
|
||||
successCallback: (result: Result) => {},
|
||||
progressCallback: (result: Result) => {},
|
||||
failureCallback: (result: Result, error: string) => {},
|
||||
infoCallback: (msg: string) => {}
|
||||
};
|
||||
};
|
||||
|
||||
const fakeStatus = (
|
||||
code: number,
|
||||
requestCharge: number
|
||||
): {
|
||||
attributes: {
|
||||
"x-ms-request-charge": number;
|
||||
"x-ms-total-request-charge": number;
|
||||
};
|
||||
code: number;
|
||||
message: string;
|
||||
} => ({
|
||||
attributes: {
|
||||
"x-ms-request-charge": requestCharge,
|
||||
"x-ms-total-request-charge": -123
|
||||
},
|
||||
code: code,
|
||||
message: null
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.sandbox.create();
|
||||
fakeSocket = {
|
||||
fakeResponse: null,
|
||||
onopen: null,
|
||||
onerror: null,
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
send: (msg: any) => {
|
||||
if (fakeSocket.onmessage) {
|
||||
fakeSocket.onmessage(fakeSocket.fakeResponse);
|
||||
}
|
||||
},
|
||||
close: () => {}
|
||||
};
|
||||
sandbox.stub(GremlinSimpleClient, "createWebSocket").returns(fakeSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it("should encode utf-8 strings when building Gremlin message", () => {
|
||||
const msg = "é";
|
||||
const expected = [16, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 47, 106, 115, 111, 110, 34, 195, 169, 34];
|
||||
const actual = GremlinSimpleClient.buildGremlinMessage(msg);
|
||||
|
||||
// TODO Array.from(actual) isn't supported in phantomJS. Must iterate through for now
|
||||
const actualArray = [];
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
actualArray.push(actual[i]);
|
||||
}
|
||||
|
||||
expect(actualArray).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should decode response from Gremlin server", () => {
|
||||
const expectedDecodedData = {
|
||||
requestId: "d772f897-0d4d-4cd1-b360-ddf6c86b93a3",
|
||||
status: {
|
||||
code: 200,
|
||||
attributes: { graphExecutionStatus: 200, StorageRU: 2.29, ComputeRU: 1.07, PerPartitionComputeCharges: {} },
|
||||
message: ""
|
||||
},
|
||||
result: { data: ["é"], meta: {} }
|
||||
};
|
||||
const expectedDecodedUint8ArrayValues = [
|
||||
123,
|
||||
34,
|
||||
114,
|
||||
101,
|
||||
113,
|
||||
117,
|
||||
101,
|
||||
115,
|
||||
116,
|
||||
73,
|
||||
100,
|
||||
34,
|
||||
58,
|
||||
34,
|
||||
100,
|
||||
55,
|
||||
55,
|
||||
50,
|
||||
102,
|
||||
56,
|
||||
57,
|
||||
55,
|
||||
45,
|
||||
48,
|
||||
100,
|
||||
52,
|
||||
100,
|
||||
45,
|
||||
52,
|
||||
99,
|
||||
100,
|
||||
49,
|
||||
45,
|
||||
98,
|
||||
51,
|
||||
54,
|
||||
48,
|
||||
45,
|
||||
100,
|
||||
100,
|
||||
102,
|
||||
54,
|
||||
99,
|
||||
56,
|
||||
54,
|
||||
98,
|
||||
57,
|
||||
51,
|
||||
97,
|
||||
51,
|
||||
34,
|
||||
44,
|
||||
34,
|
||||
115,
|
||||
116,
|
||||
97,
|
||||
116,
|
||||
117,
|
||||
115,
|
||||
34,
|
||||
58,
|
||||
123,
|
||||
34,
|
||||
99,
|
||||
111,
|
||||
100,
|
||||
101,
|
||||
34,
|
||||
58,
|
||||
50,
|
||||
48,
|
||||
48,
|
||||
44,
|
||||
34,
|
||||
97,
|
||||
116,
|
||||
116,
|
||||
114,
|
||||
105,
|
||||
98,
|
||||
117,
|
||||
116,
|
||||
101,
|
||||
115,
|
||||
34,
|
||||
58,
|
||||
123,
|
||||
34,
|
||||
103,
|
||||
114,
|
||||
97,
|
||||
112,
|
||||
104,
|
||||
69,
|
||||
120,
|
||||
101,
|
||||
99,
|
||||
117,
|
||||
116,
|
||||
105,
|
||||
111,
|
||||
110,
|
||||
83,
|
||||
116,
|
||||
97,
|
||||
116,
|
||||
117,
|
||||
115,
|
||||
34,
|
||||
58,
|
||||
50,
|
||||
48,
|
||||
48,
|
||||
44,
|
||||
34,
|
||||
83,
|
||||
116,
|
||||
111,
|
||||
114,
|
||||
97,
|
||||
103,
|
||||
101,
|
||||
82,
|
||||
85,
|
||||
34,
|
||||
58,
|
||||
50,
|
||||
46,
|
||||
50,
|
||||
57,
|
||||
44,
|
||||
34,
|
||||
67,
|
||||
111,
|
||||
109,
|
||||
112,
|
||||
117,
|
||||
116,
|
||||
101,
|
||||
82,
|
||||
85,
|
||||
34,
|
||||
58,
|
||||
49,
|
||||
46,
|
||||
48,
|
||||
55,
|
||||
44,
|
||||
34,
|
||||
80,
|
||||
101,
|
||||
114,
|
||||
80,
|
||||
97,
|
||||
114,
|
||||
116,
|
||||
105,
|
||||
116,
|
||||
105,
|
||||
111,
|
||||
110,
|
||||
67,
|
||||
111,
|
||||
109,
|
||||
112,
|
||||
117,
|
||||
116,
|
||||
101,
|
||||
67,
|
||||
104,
|
||||
97,
|
||||
114,
|
||||
103,
|
||||
101,
|
||||
115,
|
||||
34,
|
||||
58,
|
||||
123,
|
||||
125,
|
||||
125,
|
||||
44,
|
||||
34,
|
||||
109,
|
||||
101,
|
||||
115,
|
||||
115,
|
||||
97,
|
||||
103,
|
||||
101,
|
||||
34,
|
||||
58,
|
||||
34,
|
||||
34,
|
||||
125,
|
||||
44,
|
||||
34,
|
||||
114,
|
||||
101,
|
||||
115,
|
||||
117,
|
||||
108,
|
||||
116,
|
||||
34,
|
||||
58,
|
||||
123,
|
||||
34,
|
||||
100,
|
||||
97,
|
||||
116,
|
||||
97,
|
||||
34,
|
||||
58,
|
||||
91,
|
||||
34,
|
||||
195,
|
||||
169,
|
||||
34,
|
||||
93,
|
||||
44,
|
||||
34,
|
||||
109,
|
||||
101,
|
||||
116,
|
||||
97,
|
||||
34,
|
||||
58,
|
||||
123,
|
||||
125,
|
||||
125,
|
||||
125
|
||||
];
|
||||
// We do our best here to emulate what the server should return
|
||||
const gremlinResponseData = new Uint8Array(<any>expectedDecodedUint8ArrayValues).buffer;
|
||||
const client = new GremlinSimpleClient(createParams());
|
||||
const actualDecoded = client.decodeMessage(<any>{ data: gremlinResponseData });
|
||||
expect(actualDecoded).toEqual(expectedDecodedData);
|
||||
});
|
||||
|
||||
it("should connect before sending", () => {
|
||||
const params = createParams();
|
||||
params.infoCallback = sandbox.spy();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
client.executeGremlinQuery("test");
|
||||
try {
|
||||
fakeSocket.onopen();
|
||||
} catch (e) {
|
||||
// Eat expected json parse exception and not pollute test output
|
||||
}
|
||||
expect((<sinon.SinonSpy>params.infoCallback).calledOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle incoming message", () => {
|
||||
fakeSocket.fakeResponse = "test";
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(200, null),
|
||||
requestId: "id",
|
||||
result: { data: "mydata" }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
const onMessageSpy = sandbox.spy(client, "onMessage");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
expect(onMessageSpy.calledOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("should call fail callback on incoming invalid message", () => {
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const onFailureSpy = sandbox.spy(client.params, "failureCallback");
|
||||
fakeSocket.fakeResponse = {
|
||||
status: fakeStatus(200, null),
|
||||
requestId: Object.keys(client.pendingRequests)[0],
|
||||
data: new Uint8Array([1, 1, 1, 1]).buffer
|
||||
};
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 200 status code", () => {
|
||||
fakeSocket.send = (msg: any) => {};
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const onSuccessSpy = sandbox.spy(client.params, "successCallback");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
const RU = 99;
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(200, RU),
|
||||
requestId: Object.keys(client.pendingRequests)[0],
|
||||
result: { data: "mydata" }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(new MessageEvent("test2"));
|
||||
expect(
|
||||
onSuccessSpy.calledWith({
|
||||
requestId: fakeResponse.requestId,
|
||||
data: fakeResponse.result.data,
|
||||
requestCharge: RU
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 204 status code (no content)", () => {
|
||||
fakeSocket.send = (msg: any) => {};
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const onSuccessSpy = sandbox.spy(client.params, "successCallback");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
const RU = 99;
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(204, RU),
|
||||
requestId: Object.keys(client.pendingRequests)[0],
|
||||
result: { data: "THIS SHOULD BE IGNORED" }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(new MessageEvent("test2"));
|
||||
expect(
|
||||
onSuccessSpy.calledWith({
|
||||
requestId: fakeResponse.requestId,
|
||||
data: null,
|
||||
requestCharge: RU
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 206 status code (partial)", () => {
|
||||
fakeSocket.send = (msg: any) => {};
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const onSuccessSpy = sandbox.spy(client.params, "successCallback");
|
||||
const onProgressSpy = sandbox.spy(client.params, "progressCallback");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
const RU = 99;
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(206, RU),
|
||||
requestId: Object.keys(client.pendingRequests)[0],
|
||||
result: { data: [1, 2, 3] }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(new MessageEvent("test2"));
|
||||
expect(
|
||||
onProgressSpy.calledWith({
|
||||
requestId: fakeResponse.requestId,
|
||||
data: fakeResponse.result.data,
|
||||
requestCharge: RU
|
||||
})
|
||||
).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 407 status code (auth challenge)", () => {
|
||||
const socketSendStub = sandbox.stub(fakeSocket, "send").callsFake(() => {});
|
||||
const params = createParams();
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const onSuccessSpy = sandbox.spy(client.params, "successCallback");
|
||||
const buildChallengeSpy = sandbox.spy(client, "buildChallengeResponse");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(407, null),
|
||||
requestId: Object.keys(client.pendingRequests)[0],
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(new MessageEvent("test2"));
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
expect(buildChallengeSpy.calledOnce).toBe(true);
|
||||
expect(socketSendStub.calledTwice).toBe(true); // Once to send the query, once to send auth response
|
||||
});
|
||||
|
||||
describe("error status codes", () => {
|
||||
let params: GremlinSimpleClientParameters;
|
||||
let client: GremlinSimpleClient;
|
||||
let onSuccessSpy: sinon.SinonSpy;
|
||||
let onFailureSpy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeSocket.send = (msg: any) => {};
|
||||
params = createParams();
|
||||
client = new GremlinSimpleClient(params);
|
||||
onSuccessSpy = sandbox.spy(client.params, "successCallback");
|
||||
onFailureSpy = sandbox.spy(client.params, "failureCallback");
|
||||
client.executeGremlinQuery("test");
|
||||
fakeSocket.onopen();
|
||||
});
|
||||
|
||||
it("should handle 401 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(401, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 401 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(401, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 498 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(498, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 500 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(500, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 597 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(597, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 598 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(598, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle 599 status code (error)", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(599, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle unknown status code", () => {
|
||||
const fakeResponse: GremlinResponseMessage = {
|
||||
status: fakeStatus(123123123, null),
|
||||
requestId: "id",
|
||||
result: { data: <any>null }
|
||||
};
|
||||
sandbox.stub(client, "decodeMessage").returns(fakeResponse);
|
||||
client.onMessage(null);
|
||||
expect(onFailureSpy.calledOnce).toBe(true);
|
||||
expect(onSuccessSpy.notCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should build auth message", () => {
|
||||
const params = createParams();
|
||||
params.user = "éà";
|
||||
params.password = "=";
|
||||
const client = new GremlinSimpleClient(params);
|
||||
const expectedSASLResult = "AMOpw6AAPQ==";
|
||||
const request: GremlinRequestMessage = {
|
||||
requestId: "id",
|
||||
op: "eval",
|
||||
processor: "processor",
|
||||
args: {
|
||||
gremlin: "gremlin",
|
||||
bindings: {},
|
||||
language: "language"
|
||||
}
|
||||
};
|
||||
const expectedResult: GremlinRequestMessage = {
|
||||
requestId: request.requestId,
|
||||
processor: request.processor,
|
||||
op: "authentication",
|
||||
args: {
|
||||
SASL: expectedSASLResult
|
||||
}
|
||||
};
|
||||
const actual = client.buildChallengeResponse(request);
|
||||
expect(actual).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("should convert utf8 to b64", () => {
|
||||
expect(GremlinSimpleClient.utf8ToB64("小飼弾")).toEqual("5bCP6aO85by+");
|
||||
expect(GremlinSimpleClient.utf8ToB64("à mon hôpital préféré")).toEqual("w6AgbW9uIGjDtHBpdGFsIHByw6lmw6lyw6k=");
|
||||
});
|
||||
});
|
||||
357
src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.ts
Normal file
357
src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Lightweight gremlin client javascript library for the browser:
|
||||
* - specs: http://tinkerpop.apache.org/docs/3.0.1-incubating/#_developing_a_driver
|
||||
* - inspired from gremlin-javascript for nodejs: https://github.com/jbmusso/gremlin-javascript
|
||||
* - tested on cosmosdb gremlin server
|
||||
* - only supports sessionless gremlin requests
|
||||
* - Relies on text-encoding polyfill (github.com/inexorabletash/text-encoding) for TextEncoder/TextDecoder on IE, Edge.
|
||||
*/
|
||||
|
||||
import { TextEncoder, TextDecoder } from "text-encoding";
|
||||
|
||||
export interface GremlinSimpleClientParameters {
|
||||
endpoint: string; // The websocket endpoint
|
||||
user: string;
|
||||
password: string;
|
||||
successCallback: (result: Result) => void;
|
||||
progressCallback: (result: Result) => void;
|
||||
failureCallback: (result: Result, error: string) => void;
|
||||
infoCallback: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
requestId: string; // Can be null
|
||||
data: any;
|
||||
requestCharge: number; // RU cost
|
||||
}
|
||||
|
||||
// Args are for Standard OpProcessor: sessionless requests
|
||||
export interface GremlinRequestMessage {
|
||||
requestId: string;
|
||||
op: "eval" | "authentication";
|
||||
processor: string;
|
||||
args:
|
||||
| {
|
||||
gremlin: string;
|
||||
bindings: {};
|
||||
language: string;
|
||||
}
|
||||
| {
|
||||
SASL: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GremlinResponseMessage {
|
||||
requestId: string;
|
||||
status: {
|
||||
attributes: {
|
||||
/* The following fields are DEPRECATED. DO NOT USE.
|
||||
StorageRU: string;
|
||||
"x-ms-cosmosdb-graph-request-charge": number;
|
||||
*/
|
||||
|
||||
"x-ms-request-charge": number;
|
||||
"x-ms-total-request-charge": number;
|
||||
};
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
result: {
|
||||
data: any;
|
||||
};
|
||||
}
|
||||
|
||||
export class GremlinSimpleClient {
|
||||
private static readonly requestChargeHeader = "x-ms-request-charge";
|
||||
|
||||
public params: GremlinSimpleClientParameters;
|
||||
private protocols: string | string[];
|
||||
private ws: WebSocket;
|
||||
|
||||
public requestsToSend: { [requestId: string]: GremlinRequestMessage };
|
||||
public pendingRequests: { [requestId: string]: GremlinRequestMessage };
|
||||
|
||||
constructor(params: GremlinSimpleClientParameters) {
|
||||
this.params = params;
|
||||
this.pendingRequests = {};
|
||||
this.requestsToSend = {};
|
||||
}
|
||||
|
||||
public connect() {
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.CONNECTING) {
|
||||
// Wait until it connects to execute all requests
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
// Connection already open
|
||||
this.executeRequestsToSend();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.close();
|
||||
|
||||
const msg = `Connecting to ${this.params.endpoint} as ${this.params.user}`;
|
||||
if (this.params.infoCallback) {
|
||||
this.params.infoCallback(msg);
|
||||
}
|
||||
this.ws = GremlinSimpleClient.createWebSocket(this.params.endpoint);
|
||||
this.ws.onopen = this.onOpen.bind(this);
|
||||
this.ws.onerror = this.onError.bind(this);
|
||||
this.ws.onmessage = this.onMessage.bind(this);
|
||||
this.ws.onclose = this.onClose.bind(this);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
}
|
||||
|
||||
public static createWebSocket(endpoint: string): WebSocket {
|
||||
return new WebSocket(endpoint);
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSING && this.ws.readyState !== WebSocket.CLOSED) {
|
||||
const msg = `Disconnecting from ${this.params.endpoint} as ${this.params.user}`;
|
||||
console.log(msg);
|
||||
if (this.params.infoCallback) {
|
||||
this.params.infoCallback(msg);
|
||||
}
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
public decodeMessage(msg: MessageEvent): GremlinResponseMessage {
|
||||
if (msg.data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (msg.data.byteLength === 0) {
|
||||
if (this.params.infoCallback) {
|
||||
this.params.infoCallback("Received empty response");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// msg.data is an ArrayBuffer of utf-8 characters, but handle string just in case
|
||||
const data = typeof msg.data === "string" ? msg.data : new TextDecoder("utf-8").decode(msg.data);
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error(e, msg);
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(
|
||||
null,
|
||||
`Unexpected error while decoding backend response: ${e} msg:${JSON.stringify(msg)}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public onMessage(msg: MessageEvent) {
|
||||
if (!msg) {
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(null, "onMessage called with no message");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rawMessage = this.decodeMessage(msg);
|
||||
if (!rawMessage) {
|
||||
return;
|
||||
}
|
||||
const requestId = rawMessage.requestId;
|
||||
const statusCode = rawMessage.status.code;
|
||||
const statusMessage = rawMessage.status.message;
|
||||
|
||||
const result: Result = {
|
||||
requestId: requestId,
|
||||
data: rawMessage.result ? rawMessage.result.data : null,
|
||||
requestCharge: rawMessage.status.attributes[GremlinSimpleClient.requestChargeHeader]
|
||||
};
|
||||
|
||||
if (!this.pendingRequests[requestId]) {
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(
|
||||
result,
|
||||
`Received response for missing or closed request: ${requestId} code:${statusCode} message:${statusMessage}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (statusCode) {
|
||||
case 200: // Success
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.successCallback) {
|
||||
this.params.successCallback(result);
|
||||
}
|
||||
break;
|
||||
case 204: // No content
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.successCallback) {
|
||||
result.data = null;
|
||||
this.params.successCallback(result);
|
||||
}
|
||||
break;
|
||||
case 206: // Partial content
|
||||
if (this.params.progressCallback) {
|
||||
this.params.progressCallback(result);
|
||||
}
|
||||
break;
|
||||
case 407: // Request authentication
|
||||
const challengeResponse = this.buildChallengeResponse(this.pendingRequests[requestId]);
|
||||
this.sendGremlinMessage(challengeResponse);
|
||||
break;
|
||||
case 401: // Unauthorized
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Unauthorized: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
case 498: // Malformed request
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Malformed request: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
case 500: // Server error
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Server error: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
case 597: // Script eval error
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Script eval error: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
case 598: // Server timeout
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Server timeout: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
case 599: // Server serialization error
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Server serialization error: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
delete this.pendingRequests[requestId];
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(result, `Error with status code: ${statusCode}. Message: ${statusMessage}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the main function to use in order to execute a GremlinQuery
|
||||
* @param query
|
||||
* @param successCallback
|
||||
* @param progressCallback
|
||||
* @param failureCallback
|
||||
* @return requestId
|
||||
*/
|
||||
public executeGremlinQuery(query: string): string {
|
||||
const requestId = GremlinSimpleClient.uuidv4();
|
||||
this.requestsToSend[requestId] = {
|
||||
requestId: requestId,
|
||||
op: "eval",
|
||||
processor: "",
|
||||
args: {
|
||||
gremlin: query,
|
||||
bindings: {},
|
||||
language: "gremlin-groovy"
|
||||
}
|
||||
};
|
||||
this.connect();
|
||||
return requestId;
|
||||
}
|
||||
|
||||
public buildChallengeResponse(request: GremlinRequestMessage): GremlinRequestMessage {
|
||||
var args = {
|
||||
SASL: GremlinSimpleClient.utf8ToB64("\0" + this.params.user + "\0" + this.params.password)
|
||||
};
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
processor: request.processor,
|
||||
op: "authentication",
|
||||
args
|
||||
};
|
||||
}
|
||||
|
||||
public static utf8ToB64(utf8Str: string) {
|
||||
return btoa(
|
||||
encodeURIComponent(utf8Str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
|
||||
return String.fromCharCode(parseInt(p1, 16));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gremlin binary frame is:
|
||||
* mimeLength + mimeType + serialized message
|
||||
* @param requestMessage
|
||||
*/
|
||||
public static buildGremlinMessage(requestMessage: {}): Uint8Array {
|
||||
const mimeType = "application/json";
|
||||
let serializedMessage = mimeType + JSON.stringify(requestMessage);
|
||||
const encodedMessage = new TextEncoder().encode(serializedMessage);
|
||||
|
||||
let binaryMessage = new Uint8Array(1 + encodedMessage.length);
|
||||
binaryMessage[0] = mimeType.length;
|
||||
|
||||
for (let i = 0; i < encodedMessage.length; i++) {
|
||||
binaryMessage[i + 1] = encodedMessage[i];
|
||||
}
|
||||
return binaryMessage;
|
||||
}
|
||||
|
||||
private onOpen(event: any) {
|
||||
this.executeRequestsToSend();
|
||||
}
|
||||
|
||||
private executeRequestsToSend() {
|
||||
for (let requestId in this.requestsToSend) {
|
||||
const request = this.requestsToSend[requestId];
|
||||
this.sendGremlinMessage(request);
|
||||
this.pendingRequests[request.requestId] = request;
|
||||
delete this.requestsToSend[request.requestId];
|
||||
}
|
||||
}
|
||||
|
||||
private onError(err: any) {
|
||||
if (this.params.failureCallback) {
|
||||
this.params.failureCallback(null, err);
|
||||
}
|
||||
}
|
||||
|
||||
private onClose(event: CloseEvent) {
|
||||
this.requestsToSend = {};
|
||||
this.pendingRequests = {};
|
||||
|
||||
if (event.wasClean) {
|
||||
this.params.infoCallback(`Closed connection (${event.code} ${event.reason})`);
|
||||
} else {
|
||||
this.params.failureCallback(null, `Unexpectedly closed connection (${event.code} ${event.reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC4122 version 4 compliant UUID
|
||||
*/
|
||||
private static uuidv4() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
private sendGremlinMessage(gremlinRequestMessage: GremlinRequestMessage) {
|
||||
const gremlinFrame = GremlinSimpleClient.buildGremlinMessage(gremlinRequestMessage);
|
||||
this.ws.send(gremlinFrame);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import { FocusZone } from "office-ui-fabric-react/lib/FocusZone";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
|
||||
export interface CaptionId {
|
||||
caption: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface LeftPaneComponentProps {
|
||||
isFilterGraphEmptyResult: boolean;
|
||||
possibleRootNodes: CaptionId[];
|
||||
onRootNodeSelected: (id: string) => void;
|
||||
selectedRootId: string;
|
||||
isUiBusy: boolean;
|
||||
onLoadNextPage: () => void;
|
||||
hasMoreRoots: boolean;
|
||||
}
|
||||
|
||||
export class LeftPaneComponent extends React.Component<LeftPaneComponentProps> {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="leftPane">
|
||||
<div className="paneTitle leftPaneResults">Results</div>
|
||||
<div className="leftPaneContent contentScroll">
|
||||
<div className="leftPaneContainer">
|
||||
{this.props.isFilterGraphEmptyResult && <div>None</div>}
|
||||
<FocusZone as="table" className="table table-hover">
|
||||
<tbody>
|
||||
{this.props.possibleRootNodes.map((rootNode: CaptionId) => this.renderRootNodeRow(rootNode))}
|
||||
</tbody>
|
||||
</FocusZone>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loadMore">
|
||||
{this.props.hasMoreRoots && (
|
||||
<AccessibleElement role="link" as="a" onActivated={this.props.onLoadNextPage} aria-label="Load More nodes">
|
||||
Load more
|
||||
</AccessibleElement>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderRootNodeRow(node: CaptionId): JSX.Element {
|
||||
let className = "pointer";
|
||||
if (this.props.selectedRootId === node.id) {
|
||||
className += " gridRowSelected";
|
||||
}
|
||||
|
||||
if (this.props.isUiBusy) {
|
||||
className += " disabled";
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleElement
|
||||
className={className}
|
||||
as="tr"
|
||||
aria-label={node.caption}
|
||||
onActivated={e => this.props.onRootNodeSelected(node.id)}
|
||||
key={node.id}
|
||||
>
|
||||
<td className="resultItem">
|
||||
<a title={node.caption}>{node.caption}</a>
|
||||
</td>
|
||||
</AccessibleElement>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import { GraphVizComponent, GraphVizComponentProps } from "./GraphVizComponent";
|
||||
import CollapseArrowIcon from "../../../../images/Collapse_arrow_14x14.svg";
|
||||
import ExpandIcon from "../../../../images/Expand_14x14.svg";
|
||||
import LoadingIndicatorIcon from "../../../../images/LoadingIndicator_3Squares.gif";
|
||||
|
||||
interface MiddlePaneComponentProps {
|
||||
isTabsContentExpanded: boolean;
|
||||
toggleExpandGraph: () => void;
|
||||
isBackendExecuting: boolean;
|
||||
graphVizProps: GraphVizComponentProps;
|
||||
}
|
||||
|
||||
export class MiddlePaneComponent extends React.Component<MiddlePaneComponentProps> {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="middlePane">
|
||||
<div className="graphTitle">
|
||||
<span className="paneTitle">Graph</span>
|
||||
<span className="graphExpandCollapseBtn pull-right" onClick={this.props.toggleExpandGraph}>
|
||||
<img
|
||||
src={this.props.isTabsContentExpanded ? CollapseArrowIcon : ExpandIcon}
|
||||
alt={this.props.isTabsContentExpanded ? "collapse graph content" : "expand graph content"}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="maingraphContainer">
|
||||
<GraphVizComponent forceGraphParams={this.props.graphVizProps.forceGraphParams} />
|
||||
{this.props.isBackendExecuting && (
|
||||
<div className="graphModal">
|
||||
<img src={LoadingIndicatorIcon} alt="Loading Indicator" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
import * as Q from "q";
|
||||
import { NodePropertiesComponent, NodePropertiesComponentProps, Mode } from "./NodePropertiesComponent";
|
||||
import { GraphHighlightedNodeData, EditedProperties, EditedEdges, PossibleVertex } from "./GraphExplorer";
|
||||
|
||||
describe("Property pane", () => {
|
||||
const title = "My Title";
|
||||
const nodeId = "NodeId";
|
||||
const label = "My label";
|
||||
const properties = { key: ["value"] };
|
||||
|
||||
const highlightedNode: GraphHighlightedNodeData = {
|
||||
id: nodeId,
|
||||
label: label,
|
||||
properties: properties,
|
||||
areNeighborsUnknown: false,
|
||||
sources: [
|
||||
{
|
||||
name: "sourceName",
|
||||
id: "sourceId",
|
||||
edgeId: "edgeId",
|
||||
edgeLabel: "sourceEdgeLabel"
|
||||
}
|
||||
],
|
||||
targets: [
|
||||
{
|
||||
name: "targetName",
|
||||
id: "targetId",
|
||||
edgeId: "edgeId",
|
||||
edgeLabel: "targetEdgeLabel"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const createMockProps = (): NodePropertiesComponentProps => {
|
||||
return {
|
||||
expandedTitle: title,
|
||||
isCollapsed: false,
|
||||
onCollapsedChanged: (newValue: boolean): void => {},
|
||||
node: highlightedNode,
|
||||
getPkIdFromNodeData: (v: GraphHighlightedNodeData): string => null,
|
||||
collectionPartitionKeyProperty: null,
|
||||
updateVertexProperties: (editedProperties: EditedProperties): Q.Promise<void> => Q.resolve(),
|
||||
selectNode: (id: string): void => {},
|
||||
updatePossibleVertices: (): Q.Promise<PossibleVertex[]> => Q.resolve(null),
|
||||
possibleEdgeLabels: null,
|
||||
editGraphEdges: (editedEdges: EditedEdges): Q.Promise<any> => Q.resolve(),
|
||||
deleteHighlightedNode: (): void => {},
|
||||
onModeChanged: (newMode: Mode): void => {},
|
||||
viewMode: Mode.READONLY_PROP
|
||||
};
|
||||
};
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
describe("in any state", () => {
|
||||
beforeEach(() => {
|
||||
const props: NodePropertiesComponentProps = createMockProps();
|
||||
wrapper = mount(<NodePropertiesComponent {...props} />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("should display expanded title", () => {
|
||||
expect(wrapper.exists(".expandedTitle")).toBe(true);
|
||||
expect(wrapper.find(".expandedTitle").text()).toEqual(title);
|
||||
});
|
||||
|
||||
it("should display id", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === nodeId).filter(".vertexId");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display label", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === label).filter(".vertexLabel");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display property key", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "key").filter(".propertyId");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display property value", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === properties["key"][0]).filter(".propertyValue");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when neighbors are known", () => {
|
||||
beforeEach(() => {
|
||||
const props: NodePropertiesComponentProps = createMockProps();
|
||||
props.node.areNeighborsUnknown = false;
|
||||
wrapper = mount(<NodePropertiesComponent {...props} />);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("should display source name", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "sourceName").filter("a");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display source edge label", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "sourceEdgeLabel").filter("td");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display target name", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "targetName").filter("a");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display target edge label", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "targetEdgeLabel").filter("td");
|
||||
expect(cols.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should display three edit buttons", () => {
|
||||
expect(wrapper.find("span.editBtn").length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when neighbors are unknown", () => {
|
||||
beforeEach(() => {
|
||||
const props: NodePropertiesComponentProps = createMockProps();
|
||||
props.node.areNeighborsUnknown = true;
|
||||
wrapper = mount(<NodePropertiesComponent {...props} />);
|
||||
});
|
||||
|
||||
it("should not display source name", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "sourceName").filter("a");
|
||||
expect(cols.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not display source edge label", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "sourceEdgeLabel").filter("td");
|
||||
expect(cols.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not display target name", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "targetName").filter("a");
|
||||
expect(cols.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not display target edge label", () => {
|
||||
const cols = wrapper.findWhere((n: ReactWrapper) => n.text() === "targetEdgeLabel").filter("td");
|
||||
expect(cols.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should display one edit button", () => {
|
||||
expect(wrapper.find("span.editBtn").length).toBe(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Graph React component
|
||||
* Display of properties
|
||||
* The mode is controlled by the parent of this component
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { GraphHighlightedNodeData, EditedProperties, EditedEdges, PossibleVertex } from "./GraphExplorer";
|
||||
import { CollapsiblePanel } from "../../Controls/CollapsiblePanel/CollapsiblePanel";
|
||||
import { ReadOnlyNodePropertiesComponent } from "./ReadOnlyNodePropertiesComponent";
|
||||
import { EditorNodePropertiesComponent } from "./EditorNodePropertiesComponent";
|
||||
import { ReadOnlyNeighborsComponent } from "./ReadOnlyNeighborsComponent";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Item } from "../../Controls/InputTypeahead/InputTypeaheadComponent";
|
||||
import * as EditorNeighbors from "./EditorNeighborsComponent";
|
||||
import EditIcon from "../../../../images/edit.svg";
|
||||
import DeleteIcon from "../../../../images/delete.svg";
|
||||
import CheckIcon from "../../../../images/check.svg";
|
||||
import CancelIcon from "../../../../images/cancel.svg";
|
||||
import { GraphExplorer } from "./GraphExplorer";
|
||||
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
|
||||
export enum Mode {
|
||||
READONLY_PROP,
|
||||
PROPERTY_EDITOR,
|
||||
EDIT_SOURCES,
|
||||
EDIT_TARGETS
|
||||
}
|
||||
|
||||
export interface NodePropertiesComponentProps {
|
||||
expandedTitle: string;
|
||||
isCollapsed: boolean;
|
||||
onCollapsedChanged: (newValue: boolean) => void;
|
||||
|
||||
node: GraphHighlightedNodeData;
|
||||
getPkIdFromNodeData: (v: GraphHighlightedNodeData) => string;
|
||||
collectionPartitionKeyProperty: string;
|
||||
updateVertexProperties: (editedProperties: EditedProperties) => Q.Promise<void>;
|
||||
selectNode: (id: string) => void;
|
||||
updatePossibleVertices: () => Q.Promise<PossibleVertex[]>;
|
||||
possibleEdgeLabels: Item[];
|
||||
editGraphEdges: (editedEdges: EditedEdges) => Q.Promise<any>;
|
||||
deleteHighlightedNode: () => void;
|
||||
onModeChanged: (newMode: Mode) => void;
|
||||
viewMode: Mode; // If viewMode is specified in parent, keep state in sync with it
|
||||
}
|
||||
|
||||
interface NodePropertiesComponentState {
|
||||
possibleVertices: PossibleVertex[];
|
||||
editedProperties: EditedProperties;
|
||||
editedSources: EditedEdges;
|
||||
editedTargets: EditedEdges;
|
||||
isDeleteConfirm: boolean;
|
||||
isPropertiesExpanded: boolean;
|
||||
isSourcesExpanded: boolean;
|
||||
isTargetsExpanded: boolean;
|
||||
}
|
||||
|
||||
export class NodePropertiesComponent extends React.Component<
|
||||
NodePropertiesComponentProps,
|
||||
NodePropertiesComponentState
|
||||
> {
|
||||
private static readonly PROPERTIES_COLLAPSED_TITLE = "Properties";
|
||||
|
||||
public constructor(props: NodePropertiesComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
editedProperties: {
|
||||
pkId: null,
|
||||
readOnlyProperties: [],
|
||||
existingProperties: [],
|
||||
addedProperties: [],
|
||||
droppedKeys: []
|
||||
},
|
||||
editedSources: {
|
||||
vertexId: undefined,
|
||||
currentNeighbors: [],
|
||||
droppedIds: [],
|
||||
addedEdges: []
|
||||
},
|
||||
editedTargets: {
|
||||
vertexId: undefined,
|
||||
currentNeighbors: [],
|
||||
droppedIds: [],
|
||||
addedEdges: []
|
||||
},
|
||||
possibleVertices: [],
|
||||
isDeleteConfirm: false,
|
||||
isPropertiesExpanded: true,
|
||||
isSourcesExpanded: true,
|
||||
isTargetsExpanded: true
|
||||
};
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(
|
||||
props: NodePropertiesComponentProps,
|
||||
state: NodePropertiesComponentState
|
||||
): Partial<NodePropertiesComponentState> {
|
||||
if (props.viewMode !== Mode.READONLY_PROP) {
|
||||
return { isDeleteConfirm: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
if (!this.props.node) {
|
||||
return <span />;
|
||||
} else {
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
collapsedTitle={NodePropertiesComponent.PROPERTIES_COLLAPSED_TITLE}
|
||||
expandedTitle={this.props.expandedTitle}
|
||||
isCollapsed={this.props.isCollapsed}
|
||||
onCollapsedChanged={this.props.onCollapsedChanged.bind(this)}
|
||||
>
|
||||
{this.getHeaderFragment()}
|
||||
|
||||
<div className="rightPaneContent contentScroll">
|
||||
<div className="rightPaneContainer">
|
||||
{this.getPropertiesFragment()}
|
||||
{this.getNeighborsContainer()}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type option. Limit to string, number or boolean
|
||||
* @param value
|
||||
*/
|
||||
private static getTypeOption(value: any): ViewModels.InputPropertyValueTypeString {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
let type = typeof value;
|
||||
switch (type) {
|
||||
case "number":
|
||||
case "boolean":
|
||||
return type;
|
||||
case "undefined":
|
||||
return "null";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
private setMode(newMode: Mode): void {
|
||||
this.props.onModeChanged(newMode);
|
||||
}
|
||||
|
||||
private saveProperties(): void {
|
||||
this.props.updateVertexProperties(this.state.editedProperties).then(() => this.setMode(Mode.READONLY_PROP));
|
||||
}
|
||||
|
||||
private showPropertyEditor(): void {
|
||||
const partitionKeyProperty = this.props.collectionPartitionKeyProperty;
|
||||
// deep copy highlighted node
|
||||
const readOnlyProps: ViewModels.InputProperty[] = [
|
||||
{
|
||||
key: "label",
|
||||
values: [{ value: this.props.node.label, type: "string" }]
|
||||
}
|
||||
];
|
||||
|
||||
const existingProps: ViewModels.InputProperty[] = [];
|
||||
|
||||
if (this.props.node.hasOwnProperty("properties")) {
|
||||
const hProps = this.props.node["properties"];
|
||||
for (let p in hProps) {
|
||||
const propValues = hProps[p];
|
||||
(p === partitionKeyProperty ? readOnlyProps : existingProps).push({
|
||||
key: p,
|
||||
values: propValues.map(val => ({ value: val.toString(), type: NodePropertiesComponent.getTypeOption(val) }))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newMode = Mode.PROPERTY_EDITOR;
|
||||
this.setState({
|
||||
editedProperties: {
|
||||
pkId: this.props.getPkIdFromNodeData(this.props.node),
|
||||
readOnlyProperties: readOnlyProps,
|
||||
existingProperties: existingProps,
|
||||
addedProperties: [],
|
||||
droppedKeys: []
|
||||
}
|
||||
});
|
||||
this.props.onModeChanged(newMode);
|
||||
}
|
||||
|
||||
private showSourcesEditor(): void {
|
||||
this.props.updatePossibleVertices().then((possibleVertices: PossibleVertex[]) => {
|
||||
this.setState({
|
||||
possibleVertices: possibleVertices
|
||||
});
|
||||
|
||||
const editedSources: EditedEdges = {
|
||||
vertexId: this.props.node.id,
|
||||
currentNeighbors: this.props.node.sources.slice(),
|
||||
droppedIds: [],
|
||||
addedEdges: []
|
||||
};
|
||||
|
||||
const newMode = Mode.EDIT_SOURCES;
|
||||
this.setState({
|
||||
editedProperties: this.state.editedProperties,
|
||||
editedSources: editedSources
|
||||
});
|
||||
this.props.onModeChanged(newMode);
|
||||
});
|
||||
}
|
||||
|
||||
private showTargetsEditor(): void {
|
||||
this.props.updatePossibleVertices().then((possibleVertices: PossibleVertex[]) => {
|
||||
this.setState({
|
||||
possibleVertices: possibleVertices
|
||||
});
|
||||
|
||||
const editedTargets: EditedEdges = {
|
||||
vertexId: this.props.node.id,
|
||||
currentNeighbors: this.props.node.targets.slice(),
|
||||
droppedIds: [],
|
||||
addedEdges: []
|
||||
};
|
||||
|
||||
const newMode = Mode.EDIT_TARGETS;
|
||||
this.setState({
|
||||
editedProperties: this.state.editedProperties,
|
||||
editedTargets: editedTargets
|
||||
});
|
||||
this.props.onModeChanged(newMode);
|
||||
});
|
||||
}
|
||||
|
||||
private updateVertexNeighbors(isSource: boolean): void {
|
||||
const editedEdges = isSource ? this.state.editedSources : this.state.editedTargets;
|
||||
this.props.editGraphEdges(editedEdges).then(
|
||||
() => {
|
||||
this.setMode(Mode.READONLY_PROP);
|
||||
},
|
||||
() => {
|
||||
GraphExplorer.reportToConsole(ConsoleDataType.Error, "Failed to update Vertex sources.");
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private onUpdateProperties(editedProperties: EditedProperties): void {
|
||||
this.setState({
|
||||
editedProperties: editedProperties
|
||||
});
|
||||
}
|
||||
|
||||
private onUpdateEdges(editedEdges: EditedEdges, isSource: boolean): void {
|
||||
if (isSource) {
|
||||
this.setState({ editedSources: editedEdges });
|
||||
} else {
|
||||
this.setState({ editedTargets: editedEdges });
|
||||
}
|
||||
}
|
||||
|
||||
private setIsDeleteConfirm(state: boolean): void {
|
||||
this.setState({
|
||||
isDeleteConfirm: state
|
||||
});
|
||||
}
|
||||
|
||||
private discardChanges(): void {
|
||||
this.props.onModeChanged(Mode.READONLY_PROP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-pane expand collapse
|
||||
*/
|
||||
private expandCollapseProperties(): void {
|
||||
// Do not collapse while editing
|
||||
if (this.props.viewMode === Mode.PROPERTY_EDITOR && this.state.isPropertiesExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = this.state.isPropertiesExpanded;
|
||||
this.setState({ isPropertiesExpanded: !isExpanded });
|
||||
if (!isExpanded) {
|
||||
$("#propertiesContent").slideDown("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
} else {
|
||||
$("#propertiesContent").slideUp("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private expandCollapseSources(): void {
|
||||
// Do not collapse while editing
|
||||
if (this.props.viewMode === Mode.EDIT_SOURCES && this.state.isSourcesExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = this.state.isSourcesExpanded;
|
||||
this.setState({ isSourcesExpanded: !isExpanded });
|
||||
if (!isExpanded) {
|
||||
$("#sourcesContent").slideDown("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
} else {
|
||||
$("#sourcesContent").slideUp("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
}
|
||||
}
|
||||
private expandCollapseTargets(): void {
|
||||
// Do not collapse while editing
|
||||
if (this.props.viewMode === Mode.EDIT_TARGETS && this.state.isTargetsExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanded = this.state.isTargetsExpanded;
|
||||
this.setState({ isTargetsExpanded: !isExpanded });
|
||||
if (!isExpanded) {
|
||||
$("#targetsContent").slideDown("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
} else {
|
||||
$("#targetsContent").slideUp("fast", () => {
|
||||
/* Animation complete */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private deleteHighlightedNode(): void {
|
||||
this.setIsDeleteConfirm(false);
|
||||
this.props.deleteHighlightedNode();
|
||||
}
|
||||
|
||||
private getConfirmDeleteButtonsFragment(): JSX.Element {
|
||||
if (!this.state.isDeleteConfirm) {
|
||||
return (
|
||||
<AccessibleElement
|
||||
className="rightPaneHeaderTrashIcon rightPaneBtns"
|
||||
as="span"
|
||||
onActivated={this.setIsDeleteConfirm.bind(this, true)}
|
||||
aria-label="Delete this vertex"
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" />
|
||||
</AccessibleElement>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="deleteConfirm">
|
||||
Delete this vertex?
|
||||
<AccessibleElement
|
||||
className="rightPaneCheckMark rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Confirm delete this vertex"
|
||||
onActivated={this.deleteHighlightedNode.bind(this)}
|
||||
>
|
||||
<img src={CheckIcon} alt="Save" />
|
||||
</AccessibleElement>
|
||||
<AccessibleElement
|
||||
className="rightPaneDiscardBtn rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Cancel delete this vertex"
|
||||
onActivated={this.setIsDeleteConfirm.bind(this, false)}
|
||||
>
|
||||
<img className="discardBtn" src={CancelIcon} alt="Cancel" />
|
||||
</AccessibleElement>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaderFragment(): JSX.Element {
|
||||
return (
|
||||
<div className="rightPaneHeader">
|
||||
{this.props.viewMode === Mode.READONLY_PROP && (
|
||||
<span className="pull-right">{this.getConfirmDeleteButtonsFragment()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section Header containing the edit/cancel buttons
|
||||
*/
|
||||
private getSectionHeaderButtonFragment(
|
||||
isSectionExpanded: boolean,
|
||||
expandClickHandler: () => void,
|
||||
currentView: Mode,
|
||||
saveClickHandler: () => void
|
||||
): JSX.Element {
|
||||
if (isSectionExpanded) {
|
||||
return (
|
||||
<div className="pull-right">
|
||||
{this.props.viewMode === Mode.READONLY_PROP && !this.state.isDeleteConfirm && (
|
||||
<AccessibleElement
|
||||
className="rightPaneEditIcon rightPaneBtns editBtn"
|
||||
as="span"
|
||||
aria-label="Edit properties"
|
||||
onActivated={expandClickHandler}
|
||||
>
|
||||
<img src={EditIcon} alt="Edit" />
|
||||
</AccessibleElement>
|
||||
)}
|
||||
|
||||
{this.props.viewMode === currentView && (
|
||||
<AccessibleElement
|
||||
className="rightPaneCheckMark rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Save property changes"
|
||||
onActivated={saveClickHandler}
|
||||
>
|
||||
<img src={CheckIcon} alt="Save" />
|
||||
</AccessibleElement>
|
||||
)}
|
||||
{this.props.viewMode === currentView && (
|
||||
<AccessibleElement
|
||||
className="rightPaneDiscardBtn rightPaneBtns"
|
||||
as="span"
|
||||
aria-label="Discard property changes"
|
||||
onActivated={this.discardChanges.bind(this)}
|
||||
>
|
||||
<img className="discardBtn" src={CancelIcon} alt="Cancel" />
|
||||
</AccessibleElement>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getPropertiesFragment(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.getSectionHeaderButtonFragment(
|
||||
this.state.isPropertiesExpanded,
|
||||
this.showPropertyEditor.bind(this),
|
||||
Mode.PROPERTY_EDITOR,
|
||||
this.saveProperties.bind(this)
|
||||
)}
|
||||
<AccessibleElement
|
||||
className="sectionHeader"
|
||||
as="div"
|
||||
aria-label={this.state.isPropertiesExpanded ? "Collapse properties" : "Expand properties"}
|
||||
onActivated={this.expandCollapseProperties.bind(this)}
|
||||
>
|
||||
<span className={this.state.isPropertiesExpanded ? "expanded" : "collapsed"} />
|
||||
<span className="sectionTitle">Properties</span>
|
||||
</AccessibleElement>
|
||||
<div className="sectionContent" id="propertiesContent">
|
||||
{(this.props.viewMode === Mode.READONLY_PROP ||
|
||||
this.props.viewMode === Mode.EDIT_SOURCES ||
|
||||
this.props.viewMode === Mode.EDIT_TARGETS) && <ReadOnlyNodePropertiesComponent node={this.props.node} />}
|
||||
|
||||
{this.props.viewMode === Mode.PROPERTY_EDITOR && (
|
||||
<EditorNodePropertiesComponent
|
||||
editedProperties={this.state.editedProperties}
|
||||
onUpdateProperties={this.onUpdateProperties.bind(this)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private getNeighborContentFragment(isSource: boolean): JSX.Element {
|
||||
const editViewMode = isSource ? Mode.EDIT_SOURCES : Mode.EDIT_TARGETS;
|
||||
const editedNeighbors = isSource ? this.state.editedSources : this.state.editedTargets;
|
||||
|
||||
if (this.props.viewMode === editViewMode) {
|
||||
return (
|
||||
<EditorNeighbors.EditorNeighborsComponent
|
||||
editedNeighbors={editedNeighbors}
|
||||
isSource={isSource}
|
||||
possibleVertices={this.state.possibleVertices}
|
||||
possibleEdgeLabels={this.props.possibleEdgeLabels}
|
||||
onUpdateEdges={this.onUpdateEdges.bind(this)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ReadOnlyNeighborsComponent node={this.props.node} isSource={isSource} selectNode={this.props.selectNode} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getNeighborFragment(isSource: boolean): JSX.Element {
|
||||
const isNeighborExpanded = isSource ? this.state.isSourcesExpanded : this.state.isTargetsExpanded;
|
||||
const showNeighborEditor = isSource ? this.showSourcesEditor.bind(this) : this.showTargetsEditor.bind(this);
|
||||
const currentNeighborView = isSource ? Mode.EDIT_SOURCES : Mode.EDIT_TARGETS;
|
||||
const expandCollapseNeighbor = isSource
|
||||
? this.expandCollapseSources.bind(this)
|
||||
: this.expandCollapseTargets.bind(this);
|
||||
const sectionLabel = isSource ? "Sources" : "Targets";
|
||||
const sectionContentId = isSource ? "sourcesContent" : "targetsContent";
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.getSectionHeaderButtonFragment(
|
||||
isNeighborExpanded,
|
||||
showNeighborEditor,
|
||||
currentNeighborView,
|
||||
this.updateVertexNeighbors.bind(this, isSource)
|
||||
)}
|
||||
|
||||
<AccessibleElement
|
||||
className="sectionHeader"
|
||||
as="div"
|
||||
aria-label={`${this.state.isPropertiesExpanded ? "Collapse" : "Expand"} ${sectionLabel}`}
|
||||
onActivated={expandCollapseNeighbor}
|
||||
>
|
||||
<span className={isNeighborExpanded ? "expanded" : "collapsed"} />
|
||||
<span className="sectionTitle">{sectionLabel}</span>
|
||||
</AccessibleElement>
|
||||
|
||||
<div className="sectionContent" id={sectionContentId}>
|
||||
{this.getNeighborContentFragment(isSource)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private getNeighborsContainer(): JSX.Element {
|
||||
if (!this.props.node.areNeighborsUnknown) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{this.getNeighborFragment(true)}
|
||||
{this.getNeighborFragment(false)}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
return <React.Fragment />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import * as InputTypeaheadComponent from "../../Controls/InputTypeahead/InputTypeaheadComponent";
|
||||
import CloseIcon from "../../../../images/close-black.svg";
|
||||
|
||||
export interface QueryContainerComponentProps {
|
||||
initialQuery: string;
|
||||
latestPartialQueries: InputTypeaheadComponent.Item[];
|
||||
onExecuteClick: (query: string) => void;
|
||||
isLoading: boolean;
|
||||
onIsValidQueryChange: (isValidQuery: boolean) => void;
|
||||
}
|
||||
|
||||
interface QueryContainerComponentState {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export class QueryContainerComponent extends React.Component<
|
||||
QueryContainerComponentProps,
|
||||
QueryContainerComponentState
|
||||
> {
|
||||
public constructor(props: QueryContainerComponentProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
query: this.props.initialQuery
|
||||
};
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<div className="queryContainer">
|
||||
<InputTypeaheadComponent.InputTypeaheadComponent
|
||||
defaultValue={this.state.query}
|
||||
showCancelButton={false}
|
||||
choices={this.props.latestPartialQueries}
|
||||
onNewValue={(val: string) => this.onNewValue(val)}
|
||||
placeholder='g.V().has("name", "value")'
|
||||
typeaheadOverrideOptions={{ dynamic: false }}
|
||||
showSearchButton={false}
|
||||
onSelected={(item: InputTypeaheadComponent.Item) => this.onNewValue(item.value)}
|
||||
submitFct={(inputValue: string, selection: InputTypeaheadComponent.Item) =>
|
||||
this.onSubmit(inputValue, selection)
|
||||
}
|
||||
/>
|
||||
{this.renderQueryInputButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private static isQueryValid(query: string) {
|
||||
return query.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* InputValue takes precedence over dropdown selection
|
||||
* @param inputValue
|
||||
* @param selection
|
||||
*/
|
||||
private onSubmit(inputValue: string, selection: InputTypeaheadComponent.Item) {
|
||||
let newValue = inputValue;
|
||||
if (selection && typeof newValue === "undefined") {
|
||||
newValue = selection.value;
|
||||
}
|
||||
this.onNewValue(newValue);
|
||||
if (QueryContainerComponent.isQueryValid(newValue)) {
|
||||
this.props.onExecuteClick(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private onNewValue(newValue: string): void {
|
||||
this.setState({ query: newValue });
|
||||
this.props.onIsValidQueryChange(QueryContainerComponent.isQueryValid(newValue));
|
||||
}
|
||||
|
||||
private onClearFilterClick(): void {
|
||||
this.setState({ query: "" });
|
||||
}
|
||||
|
||||
private renderQueryInputButton(): JSX.Element {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button
|
||||
type="button"
|
||||
className="filterbtnstyle queryButton"
|
||||
onClick={e => this.props.onExecuteClick(this.state.query)}
|
||||
disabled={this.props.isLoading || !QueryContainerComponent.isQueryValid(this.state.query)}
|
||||
>
|
||||
Execute Gremlin Query
|
||||
</button>
|
||||
<span
|
||||
className="filterclose"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => this.onClearFilterClick()}
|
||||
onKeyPress={() => this.onClearFilterClick()}
|
||||
aria-label="Clear query"
|
||||
>
|
||||
<img className="refreshcol" src={CloseIcon} alt="Close" />
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Graph React component
|
||||
* Read-only neighbors (targets or sources)
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { GraphHighlightedNodeData, NeighborVertexBasicInfo } from "./GraphExplorer";
|
||||
import { GraphUtil } from "./GraphUtil";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
|
||||
export interface ReadOnlyNeighborsComponentProps {
|
||||
node: GraphHighlightedNodeData;
|
||||
isSource: boolean;
|
||||
selectNode: (id: string) => void;
|
||||
}
|
||||
|
||||
export class ReadOnlyNeighborsComponent extends React.Component<ReadOnlyNeighborsComponentProps> {
|
||||
private static readonly NO_SOURCES_LABEL = "No sources found";
|
||||
private static readonly SOURCE_TITLE = "Source";
|
||||
private static readonly NO_TARGETS_LABEL = "No targets found";
|
||||
private static readonly TARGET_TITLE = "Target";
|
||||
|
||||
public render(): JSX.Element {
|
||||
const neighbors = this.props.isSource ? this.props.node.sources : this.props.node.targets;
|
||||
const noNeighborsLabel = this.props.isSource
|
||||
? ReadOnlyNeighborsComponent.NO_SOURCES_LABEL
|
||||
: ReadOnlyNeighborsComponent.NO_TARGETS_LABEL;
|
||||
|
||||
if (neighbors.length === 0) {
|
||||
return <span className="noSourcesLabel">{noNeighborsLabel}</span>;
|
||||
} else {
|
||||
const neighborTitle = this.props.isSource
|
||||
? ReadOnlyNeighborsComponent.SOURCE_TITLE
|
||||
: ReadOnlyNeighborsComponent.TARGET_TITLE;
|
||||
return (
|
||||
<table className="edgesTable">
|
||||
<thead className="propertyTableHeader">
|
||||
<tr>
|
||||
<td>{neighborTitle}</td>
|
||||
<td className="edgeLabel">Edge label</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{neighbors.map((_neighbor: NeighborVertexBasicInfo, index: number) => (
|
||||
<tr key={`${index}_${_neighbor.id}_${_neighbor.edgeLabel}`}>
|
||||
<td>
|
||||
<AccessibleElement
|
||||
className="clickableLink"
|
||||
as="a"
|
||||
aria-label={_neighbor.name}
|
||||
onActivated={e => this.props.selectNode(_neighbor.id)}
|
||||
title={GraphUtil.getNeighborTitle(_neighbor)}
|
||||
>
|
||||
{_neighbor.name}
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
<td className="labelCol">{_neighbor.edgeLabel}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
import { GraphHighlightedNodeData } from "./GraphExplorer";
|
||||
|
||||
import {
|
||||
ReadOnlyNodePropertiesComponent,
|
||||
ReadOnlyNodePropertiesComponentProps
|
||||
} from "./ReadOnlyNodePropertiesComponent";
|
||||
|
||||
describe("<ReadOnlyNodePropertiesComponent />", () => {
|
||||
const id = "myId";
|
||||
const label = "myLabel";
|
||||
const mockNode: GraphHighlightedNodeData = {
|
||||
id: id,
|
||||
label: label,
|
||||
properties: {
|
||||
key1: ["value1"],
|
||||
key2: ["value2"]
|
||||
},
|
||||
areNeighborsUnknown: false,
|
||||
sources: [],
|
||||
targets: []
|
||||
};
|
||||
|
||||
it("renders id", () => {
|
||||
const props: ReadOnlyNodePropertiesComponentProps = { node: mockNode };
|
||||
const wrapper = shallow(<ReadOnlyNodePropertiesComponent {...props} />);
|
||||
expect(wrapper.find(".vertexId").text()).toBe(id);
|
||||
});
|
||||
|
||||
it("renders label", () => {
|
||||
const props: ReadOnlyNodePropertiesComponentProps = { node: mockNode };
|
||||
const wrapper = shallow(<ReadOnlyNodePropertiesComponent {...props} />);
|
||||
expect(wrapper.find(".vertexLabel").text()).toBe(label);
|
||||
});
|
||||
|
||||
it("renders properties (single value)", () => {
|
||||
const props: ReadOnlyNodePropertiesComponentProps = { node: mockNode };
|
||||
const wrapper = shallow(<ReadOnlyNodePropertiesComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders properties (with multiple values)", () => {
|
||||
const mockNode2 = {
|
||||
...mockNode,
|
||||
properties: {
|
||||
key3: ["abcd", 1234, true, false, undefined, null]
|
||||
}
|
||||
};
|
||||
const props: ReadOnlyNodePropertiesComponentProps = { node: mockNode2 };
|
||||
const wrapper = shallow(<ReadOnlyNodePropertiesComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders unicode", () => {
|
||||
const mockNode2 = {
|
||||
...mockNode,
|
||||
properties: {
|
||||
key4: ["あきら, アキラ,安喜良"],
|
||||
key5: ["Véronique"]
|
||||
}
|
||||
};
|
||||
const props: ReadOnlyNodePropertiesComponentProps = { node: mockNode2 };
|
||||
const wrapper = shallow(<ReadOnlyNodePropertiesComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Graph React component
|
||||
* Read-only properties
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { GraphHighlightedNodeData } from "./GraphExplorer";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export interface ReadOnlyNodePropertiesComponentProps {
|
||||
node: GraphHighlightedNodeData;
|
||||
}
|
||||
|
||||
export class ReadOnlyNodePropertiesComponent extends React.Component<ReadOnlyNodePropertiesComponentProps> {
|
||||
public render(): JSX.Element {
|
||||
return (
|
||||
<table className="roPropertyTable propertyTable">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="labelCol">id</td>
|
||||
<td>
|
||||
<span className="vertexId">{this.props.node.id}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="labelCol">label</td>
|
||||
<td>
|
||||
<span className="vertexLabel">{this.props.node.label}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{Object.keys(this.props.node.properties).map(_propkey => {
|
||||
const gremlinValues = this.props.node.properties[_propkey];
|
||||
return ReadOnlyNodePropertiesComponent.renderReadOnlyPropertyKeyPair(_propkey, gremlinValues);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
public static renderReadOnlyPropertyKeyPair(
|
||||
key: string,
|
||||
propertyValues: ViewModels.GremlinPropertyValueType[]
|
||||
): JSX.Element {
|
||||
const renderedValues = propertyValues.map(value =>
|
||||
ReadOnlyNodePropertiesComponent.renderSinglePropertyValue(value)
|
||||
);
|
||||
const stringifiedValues = propertyValues
|
||||
.map(value => ReadOnlyNodePropertiesComponent.singlePropertyValueToString(value))
|
||||
.join(", ");
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="labelCol propertyId" title={key}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="valueCol" title={stringifiedValues}>
|
||||
{renderedValues}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
public static singlePropertyValueToString(value: ViewModels.GremlinPropertyValueType): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
} else if (typeof value === "undefined") {
|
||||
return "undefined";
|
||||
} else {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
public static renderSinglePropertyValue(value: ViewModels.GremlinPropertyValueType): JSX.Element {
|
||||
let singlePropValue = value;
|
||||
let className = "propertyValue";
|
||||
if (singlePropValue === null) {
|
||||
singlePropValue = "null";
|
||||
className += " isNull";
|
||||
} else if (typeof singlePropValue === "undefined") {
|
||||
singlePropValue = "undefined";
|
||||
} else {
|
||||
singlePropValue = value.toString();
|
||||
}
|
||||
return (
|
||||
<div key={singlePropValue} className={className}>
|
||||
{singlePropValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<EditorNodePropertiesComponent /> renders component 1`] = `
|
||||
<table
|
||||
className="propertyTable"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
key="singlevalueprop"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="singlevalueprop"
|
||||
>
|
||||
singlevalueprop
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="abcd"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="abcd"
|
||||
>
|
||||
abcd
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="multivaluesprop"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="multivaluesprop"
|
||||
>
|
||||
multivaluesprop
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="efgh, 1234, true, false, undefined, null"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="efgh"
|
||||
>
|
||||
efgh
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="1234"
|
||||
>
|
||||
1234
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="true"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="false"
|
||||
>
|
||||
false
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="undefined"
|
||||
>
|
||||
undefined
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue isNull"
|
||||
key="null"
|
||||
>
|
||||
null
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="singlevalueprop2"
|
||||
>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
singlevalueprop2
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
>
|
||||
<input
|
||||
className="edgeInput"
|
||||
onChange={[Function]}
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
value="ijkl"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="typeSelect"
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
value="string"
|
||||
>
|
||||
<option
|
||||
key="string"
|
||||
value="string"
|
||||
>
|
||||
string
|
||||
</option>
|
||||
<option
|
||||
key="number"
|
||||
value="number"
|
||||
>
|
||||
number
|
||||
</option>
|
||||
<option
|
||||
key="boolean"
|
||||
value="boolean"
|
||||
>
|
||||
boolean
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td
|
||||
className="actionCol"
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Delete property"
|
||||
as="span"
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Delete"
|
||||
src=""
|
||||
/>
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="multivaluesprop2"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
>
|
||||
multivaluesprop2
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="mnop"
|
||||
>
|
||||
mnop
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="5678"
|
||||
>
|
||||
5678
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="true"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="false"
|
||||
>
|
||||
false
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="undefined"
|
||||
>
|
||||
undefined
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue isNull"
|
||||
key="null"
|
||||
>
|
||||
null
|
||||
</div>
|
||||
</td>
|
||||
<td />
|
||||
<td
|
||||
className="actionCol"
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Remove existing property"
|
||||
as="span"
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Delete"
|
||||
src=""
|
||||
/>
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
className="rightPaneAddPropertyBtnPadding"
|
||||
colSpan={3}
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Add a property"
|
||||
as="span"
|
||||
className="rightPaneAddPropertyBtn rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Add"
|
||||
src=""
|
||||
/>
|
||||
Add Property
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`<EditorNodePropertiesComponent /> renders proper unicode 1`] = `
|
||||
<table
|
||||
className="propertyTable"
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
key="unicode1"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="unicode1"
|
||||
>
|
||||
unicode1
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="Véronique"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="Véronique"
|
||||
>
|
||||
Véronique
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="unicode2"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="unicode2"
|
||||
>
|
||||
unicode2
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="亜妃子"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="亜妃子"
|
||||
>
|
||||
亜妃子
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="unicode1"
|
||||
>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
unicode1
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
>
|
||||
<input
|
||||
className="edgeInput"
|
||||
onChange={[Function]}
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
value="André"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="typeSelect"
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
value="string"
|
||||
>
|
||||
<option
|
||||
key="string"
|
||||
value="string"
|
||||
>
|
||||
string
|
||||
</option>
|
||||
<option
|
||||
key="number"
|
||||
value="number"
|
||||
>
|
||||
number
|
||||
</option>
|
||||
<option
|
||||
key="boolean"
|
||||
value="boolean"
|
||||
>
|
||||
boolean
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td
|
||||
className="actionCol"
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Delete property"
|
||||
as="span"
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Delete"
|
||||
src=""
|
||||
/>
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="unicode2"
|
||||
>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
unicode2
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
>
|
||||
<input
|
||||
className="edgeInput"
|
||||
onChange={[Function]}
|
||||
placeholder="Value"
|
||||
type="text"
|
||||
value="あきら, アキラ,安喜良"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="typeSelect"
|
||||
onChange={[Function]}
|
||||
required={true}
|
||||
value="string"
|
||||
>
|
||||
<option
|
||||
key="string"
|
||||
value="string"
|
||||
>
|
||||
string
|
||||
</option>
|
||||
<option
|
||||
key="number"
|
||||
value="number"
|
||||
>
|
||||
number
|
||||
</option>
|
||||
<option
|
||||
key="boolean"
|
||||
value="boolean"
|
||||
>
|
||||
boolean
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td
|
||||
className="actionCol"
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Delete property"
|
||||
as="span"
|
||||
className="rightPaneTrashIcon rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Delete"
|
||||
src=""
|
||||
/>
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
className="rightPaneAddPropertyBtnPadding"
|
||||
colSpan={3}
|
||||
>
|
||||
<AccessibleElement
|
||||
aria-label="Add a property"
|
||||
as="span"
|
||||
className="rightPaneAddPropertyBtn rightPaneBtns"
|
||||
onActivated={[Function]}
|
||||
>
|
||||
<img
|
||||
alt="Add"
|
||||
src=""
|
||||
/>
|
||||
Add Property
|
||||
</AccessibleElement>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
@@ -0,0 +1,247 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ReadOnlyNodePropertiesComponent /> renders properties (single value) 1`] = `
|
||||
<table
|
||||
className="roPropertyTable propertyTable"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
id
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexId"
|
||||
>
|
||||
myId
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
label
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexLabel"
|
||||
>
|
||||
myLabel
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="key1"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="key1"
|
||||
>
|
||||
key1
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="value1"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="value1"
|
||||
>
|
||||
value1
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="key2"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="key2"
|
||||
>
|
||||
key2
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="value2"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="value2"
|
||||
>
|
||||
value2
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`<ReadOnlyNodePropertiesComponent /> renders properties (with multiple values) 1`] = `
|
||||
<table
|
||||
className="roPropertyTable propertyTable"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
id
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexId"
|
||||
>
|
||||
myId
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
label
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexLabel"
|
||||
>
|
||||
myLabel
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="key3"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="key3"
|
||||
>
|
||||
key3
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="abcd, 1234, true, false, undefined, null"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="abcd"
|
||||
>
|
||||
abcd
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="1234"
|
||||
>
|
||||
1234
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="true"
|
||||
>
|
||||
true
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="false"
|
||||
>
|
||||
false
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="undefined"
|
||||
>
|
||||
undefined
|
||||
</div>
|
||||
<div
|
||||
className="propertyValue isNull"
|
||||
key="null"
|
||||
>
|
||||
null
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
exports[`<ReadOnlyNodePropertiesComponent /> renders unicode 1`] = `
|
||||
<table
|
||||
className="roPropertyTable propertyTable"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
id
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexId"
|
||||
>
|
||||
myId
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
className="labelCol"
|
||||
>
|
||||
label
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className="vertexLabel"
|
||||
>
|
||||
myLabel
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="key4"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="key4"
|
||||
>
|
||||
key4
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="あきら, アキラ,安喜良"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="あきら, アキラ,安喜良"
|
||||
>
|
||||
あきら, アキラ,安喜良
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
key="key5"
|
||||
>
|
||||
<td
|
||||
className="labelCol propertyId"
|
||||
title="key5"
|
||||
>
|
||||
key5
|
||||
</td>
|
||||
<td
|
||||
className="valueCol"
|
||||
title="Véronique"
|
||||
>
|
||||
<div
|
||||
className="propertyValue"
|
||||
key="Véronique"
|
||||
>
|
||||
Véronique
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
622
src/Explorer/Graph/GraphExplorerComponent/graphExplorer.less
Normal file
622
src/Explorer/Graph/GraphExplorerComponent/graphExplorer.less
Normal file
@@ -0,0 +1,622 @@
|
||||
@import "../../../../less/Common/Constants";
|
||||
|
||||
/* Styles for the graph explorer component */
|
||||
|
||||
@TransitionLengthMs: 500ms;
|
||||
@PaneDivider: @DividerColor; // Used to be: #d6d7d8
|
||||
@GraphNodeDefaultColor: orange;
|
||||
@GraphNodeBackgroundColor: @BaseLight;
|
||||
@GraphRootNodeSelected: @SelectionHigh;
|
||||
@GraphSelectedNode: @AccentMediumHigh;
|
||||
@LoadGraphHelperWidth: 130px;
|
||||
@LoadGraphHelperHeight: 120px;
|
||||
@RightPaneContainerWidth: 410px;
|
||||
@LeftPaneContainerWidth: 172px;
|
||||
|
||||
.graphExplorerContainer {
|
||||
margin-left: @SmallSpace;
|
||||
overflow: hidden;
|
||||
height:100%;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.filterLoadingProgress {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: @LoaderWidth;
|
||||
height: @LoaderHeight;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
.disabled {
|
||||
background-color: @BaseLight;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.queryContainer {
|
||||
padding: (2 * @MediumSpace) @MediumSpace (2 * @LargeSpace);
|
||||
.flex-display();
|
||||
|
||||
.queryButton {
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
margin-left: @LargeSpace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterclose {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.loadGraphHelper {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
height:50%;
|
||||
|
||||
img {
|
||||
width: @LoadGraphHelperWidth;
|
||||
height: @LoadGraphHelperHeight;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: @DefaultSpace;
|
||||
}
|
||||
|
||||
.loadGraphBtn{
|
||||
margin-top: @LargeSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.graphTabContent {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graphJsonEditor {
|
||||
flex-grow: 1;
|
||||
margin: 20px;
|
||||
border: 1px solid @PaneDivider;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.jsonEditor {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override bootstrap's nav pills */
|
||||
.nav-pills>li.active>a,
|
||||
.nav-pills>li.active>a:focus,
|
||||
.nav-pills>li.active>a:hover {
|
||||
background-color: @AccentMediumHigh;
|
||||
}
|
||||
|
||||
.nav-pills>li>a {
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.nav>li>a {
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
.typeahead__field input {
|
||||
height: 25px !important;
|
||||
}
|
||||
|
||||
.typeahead__cancel-button {
|
||||
top: 5px !important;
|
||||
right: .4em !important;
|
||||
}
|
||||
|
||||
|
||||
.queryMetricsSummary {
|
||||
margin: @LargeSpace @LargeSpace 0px @DefaultSpace;
|
||||
table-layout: fixed;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.queryMetricsSummaryHead {
|
||||
.flex-display();
|
||||
}
|
||||
|
||||
.queryMetricsSummaryHeader.queryMetricsSummaryTuple {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.queryMetricsSummaryBody {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
}
|
||||
|
||||
.queryMetricsSummaryTuple {
|
||||
border-bottom: 1px solid @BaseMedium;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
.flex-display();
|
||||
th, td {
|
||||
padding: @DefaultSpace;
|
||||
|
||||
&:nth-child(1) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.graphContainer {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-top: (2 * @MediumSpace);
|
||||
.flex-display();
|
||||
|
||||
|
||||
.leftPane {
|
||||
width: 200px;
|
||||
padding: 0px 0px 0px @DefaultSpace;
|
||||
border-right: 1px solid @PaneDivider;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.leftPaneResults {
|
||||
margin: @MediumSpace 0px @DefaultSpace;
|
||||
}
|
||||
|
||||
.leftPaneContent {
|
||||
flex: 1;
|
||||
|
||||
.leftPaneContainer {
|
||||
width: @LeftPaneContainerWidth;
|
||||
padding-right: @SmallSpace;
|
||||
|
||||
table {
|
||||
table-layout: fixed;
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
background-color: @BaseLight;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Left Pane
|
||||
|
||||
.middlePane {
|
||||
flex-grow: 1;
|
||||
border-right: 1px solid @PaneDivider;
|
||||
position: relative;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.graphTitle {
|
||||
margin-top: @DefaultSpace;
|
||||
|
||||
.graphExpandCollapseBtn{
|
||||
padding:2px 6px 5px 6px;
|
||||
margin-right: @SmallSpace;
|
||||
|
||||
&:hover {
|
||||
.hover();
|
||||
}
|
||||
|
||||
&:active {
|
||||
.active();
|
||||
}
|
||||
|
||||
img{
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.maingraphContainer {
|
||||
position: relative;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
height: 100%;
|
||||
|
||||
.graphModal {
|
||||
background-color: rgba(255, 255, 255, .7);
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: @LoaderWidth;
|
||||
height: @LoaderHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Middle Pane
|
||||
|
||||
.rightPane {
|
||||
.collapsiblePanel {
|
||||
right: 0px;
|
||||
float: right;
|
||||
width: @RightPaneWidth;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
padding-top: 6px;
|
||||
|
||||
.panelContent {
|
||||
height: 100%;
|
||||
/* Override default (auto which grows as big as content) to make it grow to fill parent instead */
|
||||
min-height: 0px;
|
||||
margin-top: 0px;
|
||||
|
||||
|
||||
.rightPaneHeader {
|
||||
/* TODO: Hack to align the trashbox with the header for now. */
|
||||
margin-top: -28px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: (2 * @LargeSpace);
|
||||
|
||||
.rightPaneHeaderTrashIcon {
|
||||
margin-right: (2 * @LargeSpace);
|
||||
padding: 1px 5px 6px 5px;
|
||||
|
||||
img {
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
.deleteConfirm {
|
||||
background-color: @BaseLight;
|
||||
padding: @SmallSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneContent {
|
||||
height: 100%;
|
||||
|
||||
.rightPaneContainer {
|
||||
width: @RightPaneContainerWidth;
|
||||
padding-left: @SmallSpace;
|
||||
|
||||
.sectionHeader {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
padding: @DefaultSpace @DefaultSpace @LargeSpace (2 * @MediumSpace);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.edgesTable {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
td {
|
||||
padding: 5px 2px @SmallSpace 2px;
|
||||
|
||||
&.valueCol {
|
||||
width: 100%;
|
||||
padding-right: @SmallSpace;
|
||||
}
|
||||
|
||||
&.rightPaneAddPropertyBtnPadding {
|
||||
padding-top: @LargeSpace;
|
||||
}
|
||||
|
||||
&.edgeLabel {
|
||||
padding-right: 41px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.propertyTable {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
tr {
|
||||
vertical-align: top;
|
||||
td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0px 4px 4px 4px;
|
||||
}
|
||||
td.actionCol {
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roPropertyTable {
|
||||
table-layout: fixed;
|
||||
tr {
|
||||
height: 27px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionCol {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.labelCol {
|
||||
width: 30%;
|
||||
max-width: 100px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding-left: @SmallSpace;
|
||||
}
|
||||
}
|
||||
|
||||
.propertyValue {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.isNull {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.edgeInput {
|
||||
width: 100%;
|
||||
padding-left: @SmallSpace;
|
||||
}
|
||||
|
||||
.typeSelect {
|
||||
height: 23px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.propertyTableHeader {
|
||||
font-weight: bold;
|
||||
color: @DefaultFontColor ;
|
||||
border-bottom: 1px solid @BaseMediumHigh;
|
||||
}
|
||||
|
||||
/* Override autocomplete stuff */
|
||||
.typeahead__container {
|
||||
font: @mediumFontSize 'Segoe UI' !important;
|
||||
|
||||
input {
|
||||
font: @mediumFontSize 'Segoe UI' !important;
|
||||
padding: 0px @SmallSpace !important;
|
||||
}
|
||||
}
|
||||
|
||||
.typeahead__field input {
|
||||
height: 25px !important;
|
||||
}
|
||||
|
||||
.typeahead__cancel-button {
|
||||
top: 5px !important;
|
||||
right: .4em !important;
|
||||
}
|
||||
|
||||
.rightPaneAddPropertyBtn {
|
||||
padding: @DefaultSpace;
|
||||
margin-left: -8px;/* TODO: Hack to align the addproperty button with the section content for now. */
|
||||
|
||||
img {
|
||||
margin: 0px @SmallSpace @SmallSpace 0px;
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // .rightPane
|
||||
}
|
||||
|
||||
.queryMetricsSummaryContainer {
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paneTitle {
|
||||
padding-left: @DefaultSpace;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
margin-left: @SmallSpace;
|
||||
}
|
||||
|
||||
.filterQueryResultError {
|
||||
padding: 0px 25px;
|
||||
line-height: 25px;
|
||||
margin-bottom: 10px;
|
||||
color: @ErrorColor;
|
||||
}
|
||||
|
||||
.rightPaneEditIcon {
|
||||
padding: 1px 5px 5px 5px;
|
||||
|
||||
img {
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneCheckMark {
|
||||
padding: 1px 1px 5px 5px;
|
||||
|
||||
img {
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneDiscardBtn {
|
||||
padding: 1px @SmallSpace 5px 2px;
|
||||
margin-right: @MediumSpace;
|
||||
|
||||
.discardBtn {
|
||||
margin: 0px 2px 0px @SmallSpace;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
img {
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneTrashIcon {
|
||||
padding: @SmallSpace;
|
||||
|
||||
img {
|
||||
vertical-align: top;
|
||||
.dataExplorerIcons();
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneBtns {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.hover();
|
||||
}
|
||||
|
||||
&:active {
|
||||
.active();
|
||||
}
|
||||
}
|
||||
|
||||
/* ****************** Graph styles ************ */
|
||||
|
||||
.link.inactive {
|
||||
opacity: 0.1;
|
||||
transition: opacity @TransitionLengthMs;
|
||||
}
|
||||
|
||||
.nodes {
|
||||
circle.main {
|
||||
stroke: @GraphNodeBackgroundColor;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
text {
|
||||
pointer-events: none;
|
||||
font: 12px arial;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
stroke-opacity: 0.2;
|
||||
fill-opacity: 0.2;
|
||||
|
||||
transition: stroke-opacity @TransitionLengthMs;
|
||||
transition: fill-opacity @TransitionLengthMs;
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
.icon-background {
|
||||
display: none;
|
||||
fill: @SelectionHigh;
|
||||
}
|
||||
|
||||
&.root circle.main {
|
||||
stroke: @GraphRootNodeSelected;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
circle.main {
|
||||
stroke: @GraphSelectedNode;
|
||||
}
|
||||
|
||||
.icon-background {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#triangleRight svg polygon {
|
||||
fill: inherit;
|
||||
}
|
||||
|
||||
use {
|
||||
&.pageButton {
|
||||
fill: @BaseLow;
|
||||
|
||||
&.active {
|
||||
fill: @AccentMediumHigh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#loadMoreIcon svg ellipse {
|
||||
fill: inherit;
|
||||
}
|
||||
|
||||
.loadmore {
|
||||
use.loadMoreIcon {
|
||||
fill:@GraphNodeDefaultColor;
|
||||
|
||||
&.active {
|
||||
fill: @AccentMediumHigh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markerEnd polygon {
|
||||
fill: inherit;
|
||||
}
|
||||
|
||||
.markerEndContainer {
|
||||
use.markerEnd {
|
||||
&.inactive {
|
||||
opacity: 0.1;
|
||||
transition: opacity @TransitionLengthMs;
|
||||
}
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* scroll for leftpane, rightpane and newvertex pane*/
|
||||
|
||||
.contentScroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
51
src/Explorer/Graph/GraphStyleComponent/GraphStyle.test.ts
Normal file
51
src/Explorer/Graph/GraphStyleComponent/GraphStyle.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as ko from "knockout";
|
||||
import { GraphStyleComponent, GraphStyleParams } from "./GraphStyleComponent";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
function buildComponent(buttonOptions: any) {
|
||||
document.body.innerHTML = GraphStyleComponent.template as any;
|
||||
const vm = new GraphStyleComponent.viewModel(buttonOptions);
|
||||
ko.applyBindings(vm);
|
||||
}
|
||||
|
||||
describe("Graph Style Component", () => {
|
||||
let buildParams = (config: ViewModels.GraphConfigUiData): GraphStyleParams => {
|
||||
return {
|
||||
config: config
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
ko.cleanNode(document);
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should display proper list of choices passed in component parameters", () => {
|
||||
const PROP2 = "prop2";
|
||||
const PROPC = "prop3";
|
||||
const params = buildParams({
|
||||
nodeCaptionChoice: ko.observable(null),
|
||||
nodeIconChoice: ko.observable(null),
|
||||
nodeColorKeyChoice: ko.observable(null),
|
||||
nodeIconSet: ko.observable(null),
|
||||
nodeProperties: ko.observableArray(["prop1", PROP2]),
|
||||
nodePropertiesWithNone: ko.observableArray(["propa", "propb", PROPC]),
|
||||
showNeighborType: ko.observable(null)
|
||||
});
|
||||
|
||||
buildComponent(params);
|
||||
|
||||
var e: any = document.querySelector(".graphStyle #nodeCaptionChoices");
|
||||
expect(e.options.length).toBe(2);
|
||||
expect(e.options[1].value).toBe(PROP2);
|
||||
|
||||
e = document.querySelector(".graphStyle #nodeColorKeyChoices");
|
||||
expect(e.options.length).toBe(3);
|
||||
expect(e.options[2].value).toBe(PROPC);
|
||||
|
||||
e = document.querySelector(".graphStyle #nodeIconChoices");
|
||||
expect(e.options.length).toBe(3);
|
||||
expect(e.options[2].value).toBe(PROPC);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.ts
Normal file
103
src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { WaitsForTemplateViewModel } from "../../WaitsForTemplateViewModel";
|
||||
|
||||
/**
|
||||
* Parameters for this component
|
||||
*/
|
||||
export interface GraphStyleParams {
|
||||
config: ViewModels.GraphConfigUiData;
|
||||
firstFieldHasFocus?: ko.Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Callback triggered when the template is bound to the component (for testing purposes)
|
||||
*/
|
||||
onTemplateReady?: () => void;
|
||||
}
|
||||
|
||||
class GraphStyleViewModel extends WaitsForTemplateViewModel {
|
||||
private params: GraphStyleParams;
|
||||
|
||||
public constructor(params: GraphStyleParams) {
|
||||
super();
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady && params.onTemplateReady) {
|
||||
params.onTemplateReady();
|
||||
}
|
||||
});
|
||||
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
public onAllNeighborsKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.params.config.showNeighborType(ViewModels.NeighborType.BOTH);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public onSourcesKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.params.config.showNeighborType(ViewModels.NeighborType.SOURCES_ONLY);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public onTargetsKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.params.config.showNeighborType(ViewModels.NeighborType.TARGETS_ONLY);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const template = `
|
||||
<div id="graphStyle" class="graphStyle" data-bind="setTemplateReady: true, with:params.config">
|
||||
<div class="seconddivpadding">
|
||||
<p>Show vertex (node) as</p>
|
||||
<select id="nodeCaptionChoices" class="formTree paneselect" required data-bind="options:nodeProperties,
|
||||
value:nodeCaptionChoice, hasFocus: $parent.params.firstFieldHasFocus"></select>
|
||||
</div>
|
||||
<div class="seconddivpadding">
|
||||
<p>Map this property to node color</p>
|
||||
<select id="nodeColorKeyChoices" class="formTree paneselect" required data-bind="options:nodePropertiesWithNone,
|
||||
value:nodeColorKeyChoice"></select>
|
||||
</div>
|
||||
<div class="seconddivpadding">
|
||||
<p>Map this property to node icon</p>
|
||||
<select id="nodeIconChoices" class="formTree paneselect" required data-bind="options:nodePropertiesWithNone,
|
||||
value:nodeIconChoice"></select>
|
||||
<input type="text" data-bind="value:nodeIconSet" placeholder="Icon set: blank for collection id" class="nodeIconSet" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<p class="seconddivpadding">Show</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">
|
||||
<input type="radio" id="tab11" name="graphneighbortype" class="radio" data-bind="checkedValue:2, checked:showNeighborType" />
|
||||
<label for="tab11" tabindex="0" data-bind="event: { keypress: $parent.onAllNeighborsKeyPress }">All neighbors</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" id="tab12" name="graphneighbortype" class="radio" data-bind="checkedValue:0, checked:showNeighborType" />
|
||||
<label for="tab12" tabindex="0" data-bind="event: { keypress: $parent.onSourcesKeyPress }">Sources</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" id="tab13" name="graphneighbortype" class="radio" data-bind="checkedValue:1, checked:showNeighborType" />
|
||||
<label for="tab13" tabindex="0" data-bind="event: { keypress: $parent.onTargetsKeyPress }">Targets</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export const GraphStyleComponent = {
|
||||
viewModel: GraphStyleViewModel,
|
||||
template
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<div id="graphStyle" class="graphStyle" data-bind="setTemplateReady: true, with:params.config">
|
||||
<div class="seconddivpadding">
|
||||
<p>Show vertex (node) as</p>
|
||||
<select
|
||||
id="nodeCaptionChoices"
|
||||
class="formTree paneselect"
|
||||
required
|
||||
data-bind="options:nodeProperties,
|
||||
value:nodeCaptionChoice, hasFocus: $parent.params.firstFieldHasFocus"
|
||||
></select>
|
||||
</div>
|
||||
<div class="seconddivpadding">
|
||||
<p>Map this property to node color</p>
|
||||
<select
|
||||
id="nodeColorKeyChoices"
|
||||
class="formTree paneselect"
|
||||
required
|
||||
data-bind="options:nodePropertiesWithNone,
|
||||
value:nodeColorKeyChoice"
|
||||
></select>
|
||||
</div>
|
||||
<div class="seconddivpadding">
|
||||
<p>Map this property to node icon</p>
|
||||
<select
|
||||
id="nodeIconChoices"
|
||||
class="formTree paneselect"
|
||||
required
|
||||
data-bind="options:nodePropertiesWithNone,
|
||||
value:nodeIconChoice"
|
||||
></select>
|
||||
<input
|
||||
type="text"
|
||||
data-bind="value:nodeIconSet"
|
||||
placeholder="Icon set: blank for collection id"
|
||||
class="nodeIconSet"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="seconddivpadding">Show</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="tab11"
|
||||
name="graphneighbortype"
|
||||
class="radio"
|
||||
data-bind="checkedValue:2, checked:showNeighborType"
|
||||
/>
|
||||
<label for="tab11">All neighbors</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="tab12"
|
||||
name="graphneighbortype"
|
||||
class="radio"
|
||||
data-bind="checkedValue:0, checked:showNeighborType"
|
||||
/>
|
||||
<label for="tab12">Sources</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="tab13"
|
||||
name="graphneighbortype"
|
||||
class="radio"
|
||||
data-bind="checkedValue:1, checked:showNeighborType"
|
||||
/>
|
||||
<label for="tab13">Targets</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
75
src/Explorer/Graph/NewVertexComponent/NewVertex.test.ts
Normal file
75
src/Explorer/Graph/NewVertexComponent/NewVertex.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as ko from "knockout";
|
||||
import { NewVertexComponent, NewVertexViewModel } from "./NewVertexComponent";
|
||||
|
||||
const component = NewVertexComponent;
|
||||
|
||||
describe("New Vertex Component", () => {
|
||||
let vm: NewVertexViewModel;
|
||||
let partitionKeyProperty: ko.Observable<string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = component.template as any;
|
||||
partitionKeyProperty = ko.observable(null);
|
||||
vm = new component.viewModel({
|
||||
newVertexData: null,
|
||||
partitionKeyProperty
|
||||
});
|
||||
ko.applyBindings(vm);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ko.cleanNode(document);
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should display property list with input and +Add Property", () => {
|
||||
expect(document.querySelector(".newVertexComponent .newVertexForm")).not.toBeNull();
|
||||
expect(document.querySelector(".newVertexComponent .edgeInput")).not.toBeNull();
|
||||
expect(document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should display partition key property if set", () => {
|
||||
partitionKeyProperty("testKey");
|
||||
expect(
|
||||
(document.querySelector(".newVertexComponent .newVertexForm .labelCol input") as HTMLInputElement).value
|
||||
).toEqual("testKey");
|
||||
});
|
||||
|
||||
it("should NOT display partition key property if NOT set", () => {
|
||||
expect(document.getElementsByClassName("valueCol").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Behavior", () => {
|
||||
let clickSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
clickSpy = jasmine.createSpy("Command button click spy");
|
||||
});
|
||||
|
||||
it("should add new property row when +Add property button is pressed", () => {
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
expect(document.getElementsByClassName("valueCol").length).toBe(3);
|
||||
expect(document.getElementsByClassName("rightPaneTrashIcon").length).toBe(3);
|
||||
});
|
||||
|
||||
it("should remove property row when trash button is pressed", () => {
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
|
||||
// Mark this one to delete
|
||||
const elts = document.querySelectorAll(".newVertexComponent .rightPaneTrashIconImg");
|
||||
elts[elts.length - 1].className += " deleteme";
|
||||
|
||||
document.querySelector(".newVertexComponent .rightPaneAddPropertyBtn").dispatchEvent(new Event("click"));
|
||||
document
|
||||
.querySelector(".newVertexComponent .rightPaneTrashIconImg.deleteme")
|
||||
.parentElement.dispatchEvent(new Event("click"));
|
||||
expect(document.getElementsByClassName("valueCol").length).toBe(2);
|
||||
expect(document.getElementsByClassName("rightPaneTrashIcon").length).toBe(2);
|
||||
expect(document.querySelectorAll(".newVertexComponent .rightPaneTrashIconImg.deleteme").length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<div class="newVertexComponent" data-bind="setTemplateReady: true">
|
||||
<div class="newVertexForm">
|
||||
<div class="newVertexFormRow">
|
||||
<label for="VertexLabel" class="labelCol">Label</label>
|
||||
<input
|
||||
class="edgeInput"
|
||||
type="text"
|
||||
data-bind="textInput:$data.newVertexData().label, hasFocus: $data.firstFieldHasFocus"
|
||||
aria-label="Enter vertex label"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
placeholder="Enter vertex label"
|
||||
autocomplete="off"
|
||||
id="VertexLabel"
|
||||
/>
|
||||
<div class="actionCol"></div>
|
||||
</div>
|
||||
|
||||
<!-- ko foreach:{ data:newVertexData().properties, as: 'property' } -->
|
||||
<div class="newVertexFormRow">
|
||||
<div class="labelCol">
|
||||
<input
|
||||
type="text"
|
||||
id="propertyKeyNewVertexPane"
|
||||
data-bind="textInput: property.key, attr: { 'aria-label': 'Enter key for property '+ ($index() + 1) }"
|
||||
placeholder="Key"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="valueCol">
|
||||
<input
|
||||
class="edgeInput"
|
||||
type="text"
|
||||
data-bind="textInput: property.values[0].value, , attr: { 'aria-label': 'Enter value for property '+ ($index() + 1) }"
|
||||
placeholder="Value"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="typeSelect"
|
||||
required
|
||||
data-bind="options:$parent.propertyTypes, value:property.values[0].type, attr: { 'aria-label': property.values[0].type + ': for property '+ ($index() + 1) }"
|
||||
></select>
|
||||
</div>
|
||||
<div class="actionCol">
|
||||
<div
|
||||
class="rightPaneTrashIcon rightPaneBtns"
|
||||
data-bind="click:$parent.removeNewVertexProperty.bind($parent, $index()), event: { keypress: $parent.removeNewVertexPropertyKeyPress.bind($parent, $index()) }, attr: { 'aria-label': 'Remove property '+ ($index() + 1) }"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<img class="refreshcol rightPaneTrashIconImg" src="/delete.svg" alt="Remove property" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<div class="newVertexFormRow">
|
||||
<span class="rightPaneAddPropertyBtnPadding">
|
||||
<span
|
||||
class="rightPaneAddPropertyBtn rightPaneBtns"
|
||||
id="addProperyNewVertexBtn"
|
||||
data-bind="click:onAddNewProperty, event: { keypress: onAddNewPropertyKeyPress }"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<img class="refreshcol rightPaneAddPropertyImg" src="/Add-property.svg" alt="Add property" /> Add
|
||||
Property</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
99
src/Explorer/Graph/NewVertexComponent/NewVertexComponent.ts
Normal file
99
src/Explorer/Graph/NewVertexComponent/NewVertexComponent.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as ko from "knockout";
|
||||
import { EditorNodePropertiesComponent } from "../GraphExplorerComponent/EditorNodePropertiesComponent";
|
||||
import { NewVertexData, InputProperty } from "../../../Contracts/ViewModels";
|
||||
import { WaitsForTemplateViewModel } from "../../WaitsForTemplateViewModel";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import template from "./NewVertexComponent.html";
|
||||
|
||||
/**
|
||||
* Parameters for this component
|
||||
*/
|
||||
export interface NewVertexParams {
|
||||
// Data to be edited by the component
|
||||
newVertexData: ko.Observable<NewVertexData>;
|
||||
partitionKeyProperty: ko.Observable<string>;
|
||||
firstFieldHasFocus?: ko.Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Callback triggered when the template is bound to the component (for testing purposes)
|
||||
*/
|
||||
onTemplateReady?: () => void;
|
||||
}
|
||||
|
||||
export class NewVertexViewModel extends WaitsForTemplateViewModel {
|
||||
private static readonly DEFAULT_PROPERTY_TYPE = "string";
|
||||
|
||||
private newVertexData: ko.Observable<NewVertexData>;
|
||||
private firstFieldHasFocus: ko.Observable<boolean>;
|
||||
private propertyTypes: string[];
|
||||
|
||||
public constructor(params: NewVertexParams) {
|
||||
super();
|
||||
super.onTemplateReady((isTemplateReady: boolean) => {
|
||||
if (isTemplateReady && params.onTemplateReady) {
|
||||
params.onTemplateReady();
|
||||
}
|
||||
});
|
||||
|
||||
this.newVertexData =
|
||||
params.newVertexData ||
|
||||
ko.observable({
|
||||
label: "",
|
||||
properties: <InputProperty[]>[]
|
||||
});
|
||||
this.firstFieldHasFocus = params.firstFieldHasFocus || ko.observable(false);
|
||||
this.propertyTypes = EditorNodePropertiesComponent.VERTEX_PROPERTY_TYPES;
|
||||
if (params.partitionKeyProperty) {
|
||||
params.partitionKeyProperty.subscribe((newKeyProp: string) => {
|
||||
if (!newKeyProp) {
|
||||
return;
|
||||
}
|
||||
this.addNewVertexProperty(newKeyProp);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public onAddNewProperty() {
|
||||
this.addNewVertexProperty();
|
||||
document.getElementById("propertyKeyNewVertexPane").focus();
|
||||
}
|
||||
|
||||
public onAddNewPropertyKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.onAddNewProperty();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public addNewVertexProperty(key?: string) {
|
||||
let ap = this.newVertexData().properties;
|
||||
ap.push({ key: key || "", values: [{ value: "", type: NewVertexViewModel.DEFAULT_PROPERTY_TYPE }] });
|
||||
this.newVertexData.valueHasMutated();
|
||||
}
|
||||
|
||||
public removeNewVertexProperty(index: number) {
|
||||
let ap = this.newVertexData().properties;
|
||||
ap.splice(index, 1);
|
||||
this.newVertexData.valueHasMutated();
|
||||
document.getElementById("addProperyNewVertexBtn").focus();
|
||||
}
|
||||
|
||||
public removeNewVertexPropertyKeyPress = (index: number, source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.removeNewVertexProperty(index);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for ko component registration
|
||||
*/
|
||||
export const NewVertexComponent = {
|
||||
viewModel: NewVertexViewModel,
|
||||
template
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
@import "../../../../less/Common/Constants";
|
||||
|
||||
.newVertexComponent {
|
||||
padding: @LargeSpace 20px 20px 0px;
|
||||
width: 400px;
|
||||
|
||||
.newVertexForm {
|
||||
width: 100%;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.newVertexFormRow {
|
||||
.flex-display();
|
||||
.flex-direction(@direction: row);
|
||||
padding: 4px 5px;
|
||||
|
||||
label {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.valueCol {
|
||||
flex-grow: 1;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.rightPaneAddPropertyBtnPadding {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.edgeLabel {
|
||||
padding-right: 41px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionCol {
|
||||
min-width: 30px;
|
||||
padding: 0px 4px;
|
||||
}
|
||||
|
||||
.labelCol {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
|
||||
input {
|
||||
max-width: 65px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.edgeInput {
|
||||
width: 100%;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.typeSelect {
|
||||
height: 23px;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.rightPaneTrashIcon {
|
||||
padding: 4px 1px 0px 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rightPaneTrashIconImg {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.rightPaneAddPropertyBtn {
|
||||
padding: 7px 7px 8px 8px;
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.rightPaneBtns {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: @BaseLow ;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: @AccentMediumLow;
|
||||
}
|
||||
}
|
||||
|
||||
.rightPaneAddPropertyImg {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.contentScroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user