Display edges in Cred Explorer (#489)
Previously, when expanding a node in the cred explorer, it would display the neighboring nodes, but not any information about the edges linking to that node. If the same node was reached by multiple edges, this information was not communicated to the user. As of this commit, it now concisely communicates what kind of edge was connecting the chosen node to its adjacencies. There's a new `edgeVerb` method that plugin adapters must implement, which gives a direction-based verb descriptiong of the edge, e.g. "authors" or "is authored by". Test plan: Unit tests added to the PagerankTable tests, and hand inspection. Paired with @wchargin
This commit is contained in:
parent
9b13f3d5bd
commit
8921b5b942
|
@ -7,8 +7,11 @@ import {
|
||||||
Graph,
|
Graph,
|
||||||
NodeAddress,
|
NodeAddress,
|
||||||
type NodeAddressT,
|
type NodeAddressT,
|
||||||
|
type Neighbor,
|
||||||
Direction,
|
Direction,
|
||||||
|
type Edge,
|
||||||
EdgeAddress,
|
EdgeAddress,
|
||||||
|
type EdgeAddressT,
|
||||||
} from "../../core/graph";
|
} from "../../core/graph";
|
||||||
import type {PagerankResult} from "../../core/attribution/pagerank";
|
import type {PagerankResult} from "../../core/attribution/pagerank";
|
||||||
import type {PluginAdapter} from "../pluginAdapter";
|
import type {PluginAdapter} from "../pluginAdapter";
|
||||||
|
@ -48,6 +51,44 @@ export function nodeDescription(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function edgeVerb(
|
||||||
|
address: EdgeAddressT,
|
||||||
|
direction: "FORWARD" | "BACKWARD",
|
||||||
|
adapters: $ReadOnlyArray<PluginAdapter>
|
||||||
|
): string {
|
||||||
|
const adapter = adapters.find((adapter) =>
|
||||||
|
EdgeAddress.hasPrefix(address, adapter.edgePrefix())
|
||||||
|
);
|
||||||
|
if (adapter == null) {
|
||||||
|
const result = EdgeAddress.toString(address);
|
||||||
|
console.warn(`No adapter for ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return adapter.renderer().edgeVerb(address, direction);
|
||||||
|
} catch (e) {
|
||||||
|
const result = EdgeAddress.toString(address);
|
||||||
|
console.error(`Error getting description for ${result}: ${e.message}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neighborVerb(
|
||||||
|
{node, edge}: Neighbor,
|
||||||
|
adapters: $ReadOnlyArray<PluginAdapter>
|
||||||
|
): string {
|
||||||
|
const forwardVerb = edgeVerb(edge.address, "FORWARD", adapters);
|
||||||
|
const backwardVerb = edgeVerb(edge.address, "BACKWARD", adapters);
|
||||||
|
if (edge.src === edge.dst) {
|
||||||
|
return `${forwardVerb} and ${backwardVerb}`;
|
||||||
|
} else if (edge.dst === node) {
|
||||||
|
return forwardVerb;
|
||||||
|
} else {
|
||||||
|
return backwardVerb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class PagerankTable extends React.PureComponent<Props, State> {
|
export class PagerankTable extends React.PureComponent<Props, State> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
@ -132,7 +173,7 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<RecursiveTables
|
<NodesTables
|
||||||
addresses={
|
addresses={
|
||||||
topLevelFilter == null
|
topLevelFilter == null
|
||||||
? Array.from(graph.nodes())
|
? Array.from(graph.nodes())
|
||||||
|
@ -153,7 +194,9 @@ export class PagerankTable extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
type RTState = {expanded: boolean};
|
type RTState = {expanded: boolean};
|
||||||
type RTProps = {|
|
type RTProps = {|
|
||||||
+address: NodeAddressT,
|
+node: NodeAddressT,
|
||||||
|
// Present if this RT shows a neighbor (not a top-level node)
|
||||||
|
+edge: ?Edge,
|
||||||
+graph: Graph,
|
+graph: Graph,
|
||||||
+pagerankResult: PagerankResult,
|
+pagerankResult: PagerankResult,
|
||||||
+depth: number,
|
+depth: number,
|
||||||
|
@ -167,13 +210,15 @@ class RecursiveTable extends React.PureComponent<RTProps, RTState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {address, adapters, depth, graph, pagerankResult} = this.props;
|
const {node, edge, adapters, depth, graph, pagerankResult} = this.props;
|
||||||
const {expanded} = this.state;
|
const {expanded} = this.state;
|
||||||
const probability = pagerankResult.get(address);
|
const probability = pagerankResult.get(node);
|
||||||
if (probability == null) {
|
if (probability == null) {
|
||||||
throw new Error(`no PageRank value for ${NodeAddress.toString(address)}`);
|
throw new Error(`no PageRank value for ${NodeAddress.toString(node)}`);
|
||||||
}
|
}
|
||||||
const modifiedLogScore = Math.log(probability) + 10;
|
const modifiedLogScore = Math.log(probability) + 10;
|
||||||
|
const edgeVerbString =
|
||||||
|
edge == null ? null : neighborVerb({node, edge}, adapters);
|
||||||
return [
|
return [
|
||||||
<tr
|
<tr
|
||||||
key="self"
|
key="self"
|
||||||
|
@ -193,23 +238,35 @@ class RecursiveTable extends React.PureComponent<RTProps, RTState> {
|
||||||
>
|
>
|
||||||
{expanded ? "\u2212" : "+"}
|
{expanded ? "\u2212" : "+"}
|
||||||
</button>
|
</button>
|
||||||
<span>{nodeDescription(address, adapters)}</span>
|
<span>
|
||||||
|
{edgeVerbString != null && (
|
||||||
|
<React.Fragment>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "smaller",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{edgeVerbString}
|
||||||
|
</span>{" "}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{nodeDescription(node, adapters)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{textAlign: "right"}}>{modifiedLogScore.toFixed(2)}</td>
|
<td style={{textAlign: "right"}}>{modifiedLogScore.toFixed(2)}</td>
|
||||||
</tr>,
|
</tr>,
|
||||||
expanded && (
|
expanded && (
|
||||||
<RecursiveTables
|
<NeighborsTables
|
||||||
key="children"
|
key="children"
|
||||||
addresses={Array.from(
|
neighbors={Array.from(
|
||||||
new Set( // deduplicate same node reached by several edges
|
graph.neighbors(node, {
|
||||||
Array.from(
|
direction: Direction.ANY,
|
||||||
graph.neighbors(address, {
|
nodePrefix: NodeAddress.empty,
|
||||||
direction: Direction.ANY,
|
edgePrefix: EdgeAddress.empty,
|
||||||
nodePrefix: NodeAddress.empty,
|
})
|
||||||
edgePrefix: EdgeAddress.empty,
|
|
||||||
})
|
|
||||||
).map((neighbor) => neighbor.node)
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
pagerankResult={pagerankResult}
|
pagerankResult={pagerankResult}
|
||||||
|
@ -221,7 +278,7 @@ class RecursiveTable extends React.PureComponent<RTProps, RTState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecursiveTablesProps = {|
|
type NodesTablesProps = {|
|
||||||
+addresses: $ReadOnlyArray<NodeAddressT>,
|
+addresses: $ReadOnlyArray<NodeAddressT>,
|
||||||
+graph: Graph,
|
+graph: Graph,
|
||||||
+pagerankResult: PagerankResult,
|
+pagerankResult: PagerankResult,
|
||||||
|
@ -229,27 +286,26 @@ type RecursiveTablesProps = {|
|
||||||
+adapters: $ReadOnlyArray<PluginAdapter>,
|
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
class RecursiveTables extends React.PureComponent<RecursiveTablesProps> {
|
class NodesTables extends React.PureComponent<NodesTablesProps> {
|
||||||
render() {
|
render() {
|
||||||
const {addresses, graph, pagerankResult, depth, adapters} = this.props;
|
const {addresses, graph, pagerankResult, depth, adapters} = this.props;
|
||||||
return addresses
|
return sortBy(
|
||||||
.slice()
|
addresses,
|
||||||
.sort((a, b) => {
|
(x) => {
|
||||||
const x = pagerankResult.get(a);
|
const p = pagerankResult.get(x);
|
||||||
const y = pagerankResult.get(b);
|
if (p == null) {
|
||||||
if (x == null) {
|
throw new Error(`No pagerank result for ${NodeAddress.toString(x)}`);
|
||||||
throw new Error(`No pagerank result for ${NodeAddress.toString(a)}`);
|
|
||||||
}
|
}
|
||||||
if (y == null) {
|
return -p;
|
||||||
throw new Error(`No pagerank result for ${NodeAddress.toString(b)}`);
|
},
|
||||||
}
|
(x) => x
|
||||||
return y - x;
|
)
|
||||||
})
|
|
||||||
.slice(0, MAX_TABLE_ENTRIES)
|
.slice(0, MAX_TABLE_ENTRIES)
|
||||||
.map((address) => (
|
.map((address) => (
|
||||||
<RecursiveTable
|
<RecursiveTable
|
||||||
depth={depth}
|
depth={depth}
|
||||||
address={address}
|
node={address}
|
||||||
|
edge={null}
|
||||||
graph={graph}
|
graph={graph}
|
||||||
pagerankResult={pagerankResult}
|
pagerankResult={pagerankResult}
|
||||||
key={address}
|
key={address}
|
||||||
|
@ -258,3 +314,42 @@ class RecursiveTables extends React.PureComponent<RecursiveTablesProps> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NeighborsTablesProps = {|
|
||||||
|
+neighbors: $ReadOnlyArray<Neighbor>,
|
||||||
|
+graph: Graph,
|
||||||
|
+pagerankResult: PagerankResult,
|
||||||
|
+depth: number,
|
||||||
|
+adapters: $ReadOnlyArray<PluginAdapter>,
|
||||||
|
|};
|
||||||
|
|
||||||
|
class NeighborsTables extends React.PureComponent<NeighborsTablesProps> {
|
||||||
|
render() {
|
||||||
|
const {neighbors, graph, pagerankResult, depth, adapters} = this.props;
|
||||||
|
return sortBy(
|
||||||
|
neighbors,
|
||||||
|
({node}) => {
|
||||||
|
const p = pagerankResult.get(node);
|
||||||
|
if (p == null) {
|
||||||
|
throw new Error(
|
||||||
|
`No pagerank result for ${NodeAddress.toString(node)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return -p;
|
||||||
|
},
|
||||||
|
({edge}) => edge
|
||||||
|
)
|
||||||
|
.slice(0, MAX_TABLE_ENTRIES)
|
||||||
|
.map(({node, edge}) => (
|
||||||
|
<RecursiveTable
|
||||||
|
depth={depth}
|
||||||
|
node={node}
|
||||||
|
edge={edge}
|
||||||
|
graph={graph}
|
||||||
|
pagerankResult={pagerankResult}
|
||||||
|
key={edge.address}
|
||||||
|
adapters={adapters}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,13 +3,14 @@ import React from "react";
|
||||||
import {mount, shallow} from "enzyme";
|
import {mount, shallow} from "enzyme";
|
||||||
import enzymeToJSON from "enzyme-to-json";
|
import enzymeToJSON from "enzyme-to-json";
|
||||||
|
|
||||||
import {PagerankTable, nodeDescription} from "./PagerankTable";
|
import {PagerankTable, nodeDescription, neighborVerb} from "./PagerankTable";
|
||||||
import {pagerank} from "../../core/attribution/pagerank";
|
import {pagerank} from "../../core/attribution/pagerank";
|
||||||
import sortBy from "lodash.sortby";
|
import sortBy from "lodash.sortby";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Graph,
|
Graph,
|
||||||
type NodeAddressT,
|
type NodeAddressT,
|
||||||
|
Direction,
|
||||||
NodeAddress,
|
NodeAddress,
|
||||||
EdgeAddress,
|
EdgeAddress,
|
||||||
} from "../../core/graph";
|
} from "../../core/graph";
|
||||||
|
@ -31,14 +32,17 @@ function example() {
|
||||||
function addEdge(parts, src, dst) {
|
function addEdge(parts, src, dst) {
|
||||||
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
|
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
|
||||||
graph.addEdge(edge);
|
graph.addEdge(edge);
|
||||||
|
return edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
addEdge(["a"], nodes.fooAlpha, nodes.fooBeta);
|
const edges = {
|
||||||
addEdge(["b"], nodes.fooAlpha, nodes.bar1);
|
fooA: addEdge(["foo", "a"], nodes.fooAlpha, nodes.fooBeta),
|
||||||
addEdge(["c"], nodes.fooAlpha, nodes.xox);
|
fooB: addEdge(["foo", "b"], nodes.fooAlpha, nodes.bar1),
|
||||||
addEdge(["d"], nodes.bar1, nodes.bar1);
|
fooC: addEdge(["foo", "c"], nodes.fooAlpha, nodes.xox),
|
||||||
addEdge(["e"], nodes.bar1, nodes.xox);
|
barD: addEdge(["bar", "d"], nodes.bar1, nodes.bar1),
|
||||||
addEdge(["e'"], nodes.bar1, nodes.xox);
|
barE: addEdge(["bar", "e"], nodes.bar1, nodes.xox),
|
||||||
|
barF: addEdge(["bar", "f"], nodes.bar1, nodes.xox),
|
||||||
|
};
|
||||||
|
|
||||||
const adapters = [
|
const adapters = [
|
||||||
{
|
{
|
||||||
|
@ -48,8 +52,11 @@ function example() {
|
||||||
},
|
},
|
||||||
renderer: () => ({
|
renderer: () => ({
|
||||||
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
|
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
|
||||||
|
edgeVerb: (_unused_e, direction) =>
|
||||||
|
direction === "FORWARD" ? "foos" : "is fooed by",
|
||||||
}),
|
}),
|
||||||
nodePrefix: () => NodeAddress.fromParts(["foo"]),
|
nodePrefix: () => NodeAddress.fromParts(["foo"]),
|
||||||
|
edgePrefix: () => EdgeAddress.fromParts(["foo"]),
|
||||||
nodeTypes: () => [
|
nodeTypes: () => [
|
||||||
{name: "alpha", prefix: NodeAddress.fromParts(["foo", "a"])},
|
{name: "alpha", prefix: NodeAddress.fromParts(["foo", "a"])},
|
||||||
{name: "beta", prefix: NodeAddress.fromParts(["foo", "b"])},
|
{name: "beta", prefix: NodeAddress.fromParts(["foo", "b"])},
|
||||||
|
@ -62,8 +69,11 @@ function example() {
|
||||||
},
|
},
|
||||||
renderer: () => ({
|
renderer: () => ({
|
||||||
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
|
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
|
||||||
|
edgeVerb: (_unused_e, direction) =>
|
||||||
|
direction === "FORWARD" ? "bars" : "is barred by",
|
||||||
}),
|
}),
|
||||||
nodePrefix: () => NodeAddress.fromParts(["bar"]),
|
nodePrefix: () => NodeAddress.fromParts(["bar"]),
|
||||||
|
edgePrefix: () => EdgeAddress.fromParts(["bar"]),
|
||||||
nodeTypes: () => [
|
nodeTypes: () => [
|
||||||
{name: "alpha", prefix: NodeAddress.fromParts(["bar", "a"])},
|
{name: "alpha", prefix: NodeAddress.fromParts(["bar", "a"])},
|
||||||
],
|
],
|
||||||
|
@ -75,8 +85,10 @@ function example() {
|
||||||
},
|
},
|
||||||
renderer: () => ({
|
renderer: () => ({
|
||||||
nodeDescription: (_unused_arg) => `xox node!`,
|
nodeDescription: (_unused_arg) => `xox node!`,
|
||||||
|
edgeVerb: (_unused_e, _unused_direction) => `xox'd`,
|
||||||
}),
|
}),
|
||||||
nodePrefix: () => NodeAddress.fromParts(["xox"]),
|
nodePrefix: () => NodeAddress.fromParts(["xox"]),
|
||||||
|
edgePrefix: () => EdgeAddress.fromParts(["xox"]),
|
||||||
nodeTypes: () => [],
|
nodeTypes: () => [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -88,6 +100,7 @@ function example() {
|
||||||
throw new Error("Impossible!");
|
throw new Error("Impossible!");
|
||||||
},
|
},
|
||||||
nodePrefix: () => NodeAddress.fromParts(["unused"]),
|
nodePrefix: () => NodeAddress.fromParts(["unused"]),
|
||||||
|
edgePrefix: () => EdgeAddress.fromParts(["unused"]),
|
||||||
nodeTypes: () => [],
|
nodeTypes: () => [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -97,7 +110,7 @@ function example() {
|
||||||
froWeight: 1,
|
froWeight: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {adapters, nodes, graph, pagerankResult};
|
return {adapters, nodes, edges, graph, pagerankResult};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("app/credExplorer/PagerankTable", () => {
|
describe("app/credExplorer/PagerankTable", () => {
|
||||||
|
@ -165,7 +178,7 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
|
|
||||||
describe("full rendering", () => {
|
describe("full rendering", () => {
|
||||||
function exampleRender() {
|
function exampleRender() {
|
||||||
const {nodes, adapters, graph, pagerankResult} = example();
|
const {nodes, edges, adapters, graph, pagerankResult} = example();
|
||||||
const element = mount(
|
const element = mount(
|
||||||
<PagerankTable
|
<PagerankTable
|
||||||
pagerankResult={pagerankResult}
|
pagerankResult={pagerankResult}
|
||||||
|
@ -176,20 +189,13 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
verifyNoAdapterWarning();
|
verifyNoAdapterWarning();
|
||||||
const select = element.find("select");
|
const select = element.find("select");
|
||||||
expect(select).toHaveLength(1);
|
expect(select).toHaveLength(1);
|
||||||
return {nodes, adapters, graph, pagerankResult, element, select};
|
return {nodes, edges, adapters, graph, pagerankResult, element, select};
|
||||||
}
|
}
|
||||||
it("full render doesn't crash or error", () => {
|
it("full render doesn't crash or error", () => {
|
||||||
example();
|
example();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tables ", () => {
|
describe("tables", () => {
|
||||||
it("are sorted by score", () => {
|
|
||||||
const {element, graph, pagerankResult} = exampleRender();
|
|
||||||
const rows = element.find("RecursiveTable");
|
|
||||||
expect(rows).toHaveLength(Array.from(graph.nodes()).length);
|
|
||||||
const scores = rows.map((x) => pagerankResult.get(x.prop("address")));
|
|
||||||
expect(scores).toEqual(sortBy(scores).reverse());
|
|
||||||
});
|
|
||||||
function expectColumnCorrect(
|
function expectColumnCorrect(
|
||||||
element: *,
|
element: *,
|
||||||
name: string,
|
name: string,
|
||||||
|
@ -206,54 +212,114 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
const actual = tables.map((x) =>
|
const actual = tables.map((x) =>
|
||||||
tdToExpected(x.find("td").at(headerIndex))
|
tdToExpected(x.find("td").at(headerIndex))
|
||||||
);
|
);
|
||||||
const expected = tables.map((x) =>
|
const expected = tables.map((x) => addressToExpected(x.prop("node")));
|
||||||
addressToExpected(x.prop("address"))
|
|
||||||
);
|
|
||||||
expect(actual).toEqual(expected);
|
expect(actual).toEqual(expected);
|
||||||
}
|
}
|
||||||
it("has a node description column", () => {
|
describe("top-level", () => {
|
||||||
const {element, adapters} = exampleRender();
|
it("are sorted by score", () => {
|
||||||
expectColumnCorrect(
|
const {element, graph, pagerankResult} = exampleRender();
|
||||||
element,
|
const rows = element.find("RecursiveTable");
|
||||||
"Node",
|
expect(rows).toHaveLength(Array.from(graph.nodes()).length);
|
||||||
(td) => td.find("span").text(),
|
const scores = rows.map((x) => pagerankResult.get(x.prop("node")));
|
||||||
(address) => nodeDescription(address, adapters)
|
expect(scores.every((x) => x != null)).toBe(true);
|
||||||
);
|
expect(scores).toEqual(sortBy(scores).reverse());
|
||||||
verifyNoAdapterWarning();
|
});
|
||||||
});
|
it("has a node description column", () => {
|
||||||
it("has a log score column", () => {
|
const {element, adapters} = exampleRender();
|
||||||
const {element, pagerankResult} = exampleRender();
|
expectColumnCorrect(
|
||||||
expectColumnCorrect(
|
element,
|
||||||
element,
|
"Node",
|
||||||
"log(score)",
|
(td) => td.find("span").text(),
|
||||||
(td) => td.text(),
|
(address) => nodeDescription(address, adapters)
|
||||||
(address) => {
|
);
|
||||||
const probability = pagerankResult.get(address);
|
verifyNoAdapterWarning();
|
||||||
if (probability == null) {
|
});
|
||||||
throw new Error(address);
|
it("has a log score column", () => {
|
||||||
}
|
const {element, pagerankResult} = exampleRender();
|
||||||
const modifiedLogScore = Math.log(probability) + 10;
|
expectColumnCorrect(
|
||||||
return modifiedLogScore.toFixed(2);
|
element,
|
||||||
}
|
"log(score)",
|
||||||
);
|
(td) => td.text(),
|
||||||
});
|
(address) => {
|
||||||
it("subtables have depth-based styling", () => {
|
const probability = pagerankResult.get(address);
|
||||||
const {element} = exampleRender();
|
if (probability == null) {
|
||||||
const getLevel = (level) => {
|
throw new Error(address);
|
||||||
const rt = element.find("RecursiveTable").at(level);
|
}
|
||||||
const button = rt.find("button").first();
|
const modifiedLogScore = Math.log(probability) + 10;
|
||||||
return {rt, button};
|
return modifiedLogScore.toFixed(2);
|
||||||
};
|
}
|
||||||
getLevel(0).button.simulate("click");
|
);
|
||||||
getLevel(1).button.simulate("click");
|
});
|
||||||
const f = ({rt, button}) => ({
|
});
|
||||||
row: rt
|
describe("sub-tables", () => {
|
||||||
.find("tr")
|
it("have depth-based styling", () => {
|
||||||
.first()
|
const {element} = exampleRender();
|
||||||
.prop("style"),
|
const getLevel = (level) => {
|
||||||
button: button.prop("style"),
|
const rt = element.find("RecursiveTable").at(level);
|
||||||
|
const button = rt.find("button").first();
|
||||||
|
return {rt, button};
|
||||||
|
};
|
||||||
|
getLevel(0).button.simulate("click");
|
||||||
|
getLevel(1).button.simulate("click");
|
||||||
|
const f = ({rt, button}) => ({
|
||||||
|
row: rt
|
||||||
|
.find("tr")
|
||||||
|
.first()
|
||||||
|
.prop("style"),
|
||||||
|
button: button.prop("style"),
|
||||||
|
});
|
||||||
|
expect([0, 1, 2].map((x) => f(getLevel(x)))).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
it("display extra information about edges", () => {
|
||||||
|
const {element, nodes, graph, adapters} = exampleRender();
|
||||||
|
const getLevel = (level) => {
|
||||||
|
const rt = element.find("RecursiveTable").at(level);
|
||||||
|
const button = rt.find("button").first();
|
||||||
|
return {rt, button};
|
||||||
|
};
|
||||||
|
getLevel(0).button.simulate("click");
|
||||||
|
const nt = element.find("NeighborsTables");
|
||||||
|
expect(nt).toHaveLength(1);
|
||||||
|
const expectedNeighbors = Array.from(
|
||||||
|
graph.neighbors(nodes.bar1, {
|
||||||
|
direction: Direction.ANY,
|
||||||
|
nodePrefix: NodeAddress.empty,
|
||||||
|
edgePrefix: EdgeAddress.empty,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(nt.prop("neighbors")).toEqual(expectedNeighbors);
|
||||||
|
const subTables = nt.find("RecursiveTable");
|
||||||
|
expect(subTables).toHaveLength(expectedNeighbors.length);
|
||||||
|
const actualEdgeVerbs = subTables.map((x) =>
|
||||||
|
x
|
||||||
|
.find("span")
|
||||||
|
.children()
|
||||||
|
.find("span")
|
||||||
|
.text()
|
||||||
|
);
|
||||||
|
const expectedEdgeVerbs = subTables.map((x) => {
|
||||||
|
const edge = x.prop("edge");
|
||||||
|
const node = x.prop("node");
|
||||||
|
return neighborVerb({edge, node}, adapters);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(actualEdgeVerbs).toEqual(expectedEdgeVerbs);
|
||||||
|
const actualFullDescriptions = subTables.map((x) =>
|
||||||
|
x
|
||||||
|
.find("span")
|
||||||
|
.first()
|
||||||
|
.text()
|
||||||
|
);
|
||||||
|
const expectedFullDescriptions = subTables.map((x) => {
|
||||||
|
const edge = x.prop("edge");
|
||||||
|
const node = x.prop("node");
|
||||||
|
const nd = nodeDescription(node, adapters);
|
||||||
|
const ev = neighborVerb({node, edge}, adapters);
|
||||||
|
return `${ev} ${nd}`;
|
||||||
|
});
|
||||||
|
expect(actualFullDescriptions).toEqual(expectedFullDescriptions);
|
||||||
|
expect(actualFullDescriptions).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
expect([0, 1, 2].map((x) => f(getLevel(x)))).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
it("button toggles between +/- and adds sub-RecursiveTable", () => {
|
it("button toggles between +/- and adds sub-RecursiveTable", () => {
|
||||||
const {element} = exampleRender();
|
const {element} = exampleRender();
|
||||||
|
@ -261,15 +327,15 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
const button = rt().find("button");
|
const button = rt().find("button");
|
||||||
expect(button).toEqual(expect.anything());
|
expect(button).toEqual(expect.anything());
|
||||||
expect(button.text()).toEqual("+");
|
expect(button.text()).toEqual("+");
|
||||||
expect(rt().find("RecursiveTables")).toHaveLength(0);
|
expect(rt().find("NeighborsTables")).toHaveLength(0);
|
||||||
|
|
||||||
button.simulate("click");
|
button.simulate("click");
|
||||||
expect(button.text()).toEqual("\u2212");
|
expect(button.text()).toEqual("\u2212");
|
||||||
expect(rt().find("RecursiveTables")).toHaveLength(1);
|
expect(rt().find("NeighborsTables")).toHaveLength(1);
|
||||||
|
|
||||||
button.simulate("click");
|
button.simulate("click");
|
||||||
expect(button.text()).toEqual("+");
|
expect(button.text()).toEqual("+");
|
||||||
expect(rt().find("RecursiveTables")).toHaveLength(0);
|
expect(rt().find("NeighborsTables")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -305,7 +371,7 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
selectFilterByName(select, "\u2003beta");
|
selectFilterByName(select, "\u2003beta");
|
||||||
const rt = element.find("RecursiveTable");
|
const rt = element.find("RecursiveTable");
|
||||||
expect(rt).toHaveLength(1);
|
expect(rt).toHaveLength(1);
|
||||||
expect(rt.prop("address")).toEqual(example().nodes.fooBeta);
|
expect(rt.prop("node")).toEqual(example().nodes.fooBeta);
|
||||||
});
|
});
|
||||||
it("filter doesn't apply to sub-tables", () => {
|
it("filter doesn't apply to sub-tables", () => {
|
||||||
const {select, element} = exampleRender();
|
const {select, element} = exampleRender();
|
||||||
|
@ -319,7 +385,7 @@ describe("app/credExplorer/PagerankTable", () => {
|
||||||
const rts = element.find("RecursiveTable");
|
const rts = element.find("RecursiveTable");
|
||||||
expect(rts).toHaveLength(2);
|
expect(rts).toHaveLength(2);
|
||||||
const subRt = rts.last();
|
const subRt = rts.last();
|
||||||
expect(subRt.prop("address")).toEqual(example().nodes.fooAlpha);
|
expect(subRt.prop("node")).toEqual(example().nodes.fooAlpha);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -53,7 +53,16 @@ Array [
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`app/credExplorer/PagerankTable full rendering tables subtables have depth-based styling 1`] = `
|
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables display extra information about edges 1`] = `
|
||||||
|
Array [
|
||||||
|
"bars and is barred by bar: NodeAddress[\\"bar\\",\\"a\\",\\"1\\"]",
|
||||||
|
"bars xox node!",
|
||||||
|
"bars xox node!",
|
||||||
|
"is fooed by foo: NodeAddress[\\"foo\\",\\"a\\",\\"1\\"]",
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables have depth-based styling 1`] = `
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"button": Object {
|
"button": Object {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import type {Graph, NodeAddressT} from "../core/graph";
|
import type {Graph, NodeAddressT, EdgeAddressT} from "../core/graph";
|
||||||
|
|
||||||
export interface Renderer {
|
export interface Renderer {
|
||||||
nodeDescription(NodeAddressT): string;
|
nodeDescription(NodeAddressT): string;
|
||||||
|
edgeVerb(EdgeAddressT, "FORWARD" | "BACKWARD"): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginAdapter {
|
export interface PluginAdapter {
|
||||||
|
@ -11,6 +12,7 @@ export interface PluginAdapter {
|
||||||
graph(): Graph;
|
graph(): Graph;
|
||||||
renderer(): Renderer;
|
renderer(): Renderer;
|
||||||
nodePrefix(): NodeAddressT;
|
nodePrefix(): NodeAddressT;
|
||||||
|
edgePrefix(): EdgeAddressT;
|
||||||
nodeTypes(): Array<{|
|
nodeTypes(): Array<{|
|
||||||
+name: string,
|
+name: string,
|
||||||
+prefix: NodeAddressT,
|
+prefix: NodeAddressT,
|
||||||
|
|
|
@ -5,7 +5,8 @@ import type {
|
||||||
} from "../../app/pluginAdapter";
|
} from "../../app/pluginAdapter";
|
||||||
import {Graph} from "../../core/graph";
|
import {Graph} from "../../core/graph";
|
||||||
import * as N from "./nodes";
|
import * as N from "./nodes";
|
||||||
import {description} from "./render";
|
import * as E from "./edges";
|
||||||
|
import {description, edgeVerb} from "./render";
|
||||||
|
|
||||||
export async function createPluginAdapter(
|
export async function createPluginAdapter(
|
||||||
repoOwner: string,
|
repoOwner: string,
|
||||||
|
@ -38,6 +39,9 @@ class PluginAdapter implements IPluginAdapter {
|
||||||
nodePrefix() {
|
nodePrefix() {
|
||||||
return N._Prefix.base;
|
return N._Prefix.base;
|
||||||
}
|
}
|
||||||
|
edgePrefix() {
|
||||||
|
return E._Prefix.base;
|
||||||
|
}
|
||||||
nodeTypes() {
|
nodeTypes() {
|
||||||
return [
|
return [
|
||||||
{name: "Blob", prefix: N._Prefix.blob},
|
{name: "Blob", prefix: N._Prefix.blob},
|
||||||
|
@ -55,4 +59,7 @@ class Renderer implements IRenderer {
|
||||||
const address = N.fromRaw((node: any));
|
const address = N.fromRaw((node: any));
|
||||||
return description(address);
|
return description(address);
|
||||||
}
|
}
|
||||||
|
edgeVerb(edgeAddress, direction) {
|
||||||
|
return edgeVerb(E.fromRaw((edgeAddress: any)), direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import * as N from "./nodes";
|
import * as N from "./nodes";
|
||||||
|
import * as E from "./edges";
|
||||||
|
|
||||||
export function description(address: N.StructuredAddress) {
|
export function description(address: N.StructuredAddress) {
|
||||||
switch (address.type) {
|
switch (address.type) {
|
||||||
|
@ -18,3 +19,24 @@ export function description(address: N.StructuredAddress) {
|
||||||
throw new Error(`unknown type: ${(address.type: empty)}`);
|
throw new Error(`unknown type: ${(address.type: empty)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function edgeVerb(
|
||||||
|
address: E.StructuredAddress,
|
||||||
|
direction: "FORWARD" | "BACKWARD"
|
||||||
|
) {
|
||||||
|
const forward = direction === "FORWARD";
|
||||||
|
switch (address.type) {
|
||||||
|
case "HAS_TREE":
|
||||||
|
return forward ? "has tree" : "owned by";
|
||||||
|
case "HAS_PARENT":
|
||||||
|
return forward ? "has parent" : "is parent of";
|
||||||
|
case "INCLUDES":
|
||||||
|
return forward ? "includes" : "is included by";
|
||||||
|
case "BECOMES":
|
||||||
|
return forward ? "evolves to" : "evolves from";
|
||||||
|
case "HAS_CONTENTS":
|
||||||
|
return forward ? "has contents" : "is contents of";
|
||||||
|
default:
|
||||||
|
throw new Error(`unknown type: ${(address.type: empty)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,8 +6,9 @@ import type {
|
||||||
import {type Graph, NodeAddress} from "../../core/graph";
|
import {type Graph, NodeAddress} from "../../core/graph";
|
||||||
import {createGraph} from "./createGraph";
|
import {createGraph} from "./createGraph";
|
||||||
import * as N from "./nodes";
|
import * as N from "./nodes";
|
||||||
|
import * as E from "./edges";
|
||||||
import {RelationalView} from "./relationalView";
|
import {RelationalView} from "./relationalView";
|
||||||
import {description} from "./render";
|
import {description, edgeVerb} from "./render";
|
||||||
|
|
||||||
export async function createPluginAdapter(
|
export async function createPluginAdapter(
|
||||||
repoOwner: string,
|
repoOwner: string,
|
||||||
|
@ -43,6 +44,9 @@ class PluginAdapter implements IPluginAdapter {
|
||||||
nodePrefix() {
|
nodePrefix() {
|
||||||
return N._Prefix.base;
|
return N._Prefix.base;
|
||||||
}
|
}
|
||||||
|
edgePrefix() {
|
||||||
|
return E._Prefix.base;
|
||||||
|
}
|
||||||
nodeTypes() {
|
nodeTypes() {
|
||||||
return [
|
return [
|
||||||
{name: "Repository", prefix: N._Prefix.repo},
|
{name: "Repository", prefix: N._Prefix.repo},
|
||||||
|
@ -70,4 +74,7 @@ class Renderer implements IRenderer {
|
||||||
}
|
}
|
||||||
return description(entity);
|
return description(entity);
|
||||||
}
|
}
|
||||||
|
edgeVerb(edgeAddress, direction) {
|
||||||
|
return edgeVerb(E.fromRaw((edgeAddress: any)), direction);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
import * as R from "./relationalView";
|
import * as R from "./relationalView";
|
||||||
|
import * as E from "./edges";
|
||||||
|
|
||||||
export function description(e: R.Entity) {
|
export function description(e: R.Entity) {
|
||||||
const withAuthors = (x: R.AuthoredEntity) => {
|
const withAuthors = (x: R.AuthoredEntity) => {
|
||||||
|
@ -24,3 +25,22 @@ export function description(e: R.Entity) {
|
||||||
};
|
};
|
||||||
return R.match(handlers, e);
|
return R.match(handlers, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function edgeVerb(
|
||||||
|
e: E.StructuredAddress,
|
||||||
|
direction: "FORWARD" | "BACKWARD"
|
||||||
|
) {
|
||||||
|
const forward = direction === "FORWARD";
|
||||||
|
switch (e.type) {
|
||||||
|
case "AUTHORS":
|
||||||
|
return forward ? "authors" : "is authored by";
|
||||||
|
case "MERGED_AS":
|
||||||
|
return forward ? "merges" : "is merged by";
|
||||||
|
case "HAS_PARENT":
|
||||||
|
return forward ? "has parent" : "has child";
|
||||||
|
case "REFERENCES":
|
||||||
|
return forward ? "references" : "is referenced by";
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected type ${(e.type: empty)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue