Initial Move from Azure DevOps to GitHub

This commit is contained in:
Steve Faulkner
2020-05-25 21:30:55 -05:00
commit 36581fb6d9
986 changed files with 195242 additions and 0 deletions

View File

@@ -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]);
});
});

View File

@@ -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);
}
}

View 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);
});
});
});

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

@@ -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>
);
}
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}
}

View 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);
});
});

View 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);
}
}

View 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"));
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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}
/>
);
}
}

View 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);
});
});

View 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, "\\'");
}
}

View 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;
}
}

View 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
);
});
});

View 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);
}
}

View File

@@ -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=");
});
});

View 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);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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();
});
});
});

View File

@@ -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 />;
}
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}
}

View File

@@ -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>
`;

View File

@@ -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>
`;

View 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;
}
}

View 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);
});
});
});

View 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
};

View File

@@ -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>

View 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);
});
});
});

View File

@@ -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>

View 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
};

View File

@@ -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;
}
}