Split PagerankTable into smaller files (#630)

PagerankTable is getting a bit unwieldy, especially as #502 will need to
add a new pair of components. This commit splits the erstwise
PagerankTable.js into four files:

- `pagerankTable/shared`, shared utils and types
- `pagerankTable/Node`, the `NodeRow` and `NodeRowList`
- `pagerankTable/Connection`, the `ConnectionRow`, `ConnectionRowList`,
and `ConnectionView`
- `pagerankTable/Table`, the `PagerankTable` itself

This commit makes no logical changes; it is purely a reorganization.

Test plan: `yarn test`
This commit is contained in:
Dandelion Mané 2018-08-09 21:33:40 -07:00 committed by GitHub
parent dc13d460da
commit 74e00b0bfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1078 additions and 1004 deletions

View File

@ -6,7 +6,7 @@ import type {LocalStore} from "../localStore";
import CheckedLocalStore from "../checkedLocalStore";
import BrowserLocalStore from "../browserLocalStore";
import {PagerankTable} from "./PagerankTable";
import {PagerankTable} from "./pagerankTable/Table";
import {WeightConfig} from "./WeightConfig";
import RepositorySelect from "./RepositorySelect";
import {

View File

@ -8,7 +8,7 @@ import {makeRepo} from "../../core/repo";
import testLocalStore from "../testLocalStore";
import RepositorySelect from "./RepositorySelect";
import {PagerankTable} from "./PagerankTable";
import {PagerankTable} from "./pagerankTable/Table";
import {WeightConfig} from "./WeightConfig";
import {createApp, LoadingIndicator} from "./App";
import {initialState} from "./state";

View File

@ -1,381 +0,0 @@
// @flow
import sortBy from "lodash.sortby";
import React from "react";
import {
type EdgeAddressT,
type NodeAddressT,
NodeAddress,
} from "../../core/graph";
import type {
PagerankNodeDecomposition,
ScoredConnection,
} from "../../core/attribution/pagerankNodeDecomposition";
import type {Connection} from "../../core/attribution/graphToMarkovChain";
import {
type DynamicPluginAdapter,
dynamicDispatchByNode,
dynamicDispatchByEdge,
findEdgeType,
} from "../pluginAdapter";
import * as NullUtil from "../../util/null";
export function nodeDescription(
address: NodeAddressT,
adapters: $ReadOnlyArray<DynamicPluginAdapter>
): string {
const adapter = dynamicDispatchByNode(adapters, address);
try {
return adapter.nodeDescription(address);
} catch (e) {
const result = NodeAddress.toString(address);
console.error(`Error getting description for ${result}: ${e.message}`);
return result;
}
}
function edgeVerb(
address: EdgeAddressT,
direction: "FORWARD" | "BACKWARD",
adapters: $ReadOnlyArray<DynamicPluginAdapter>
): string {
const adapter = dynamicDispatchByEdge(adapters, address);
const edgeType = findEdgeType(adapter.static(), address);
return direction === "FORWARD" ? edgeType.forwardName : edgeType.backwardName;
}
function scoreDisplay(score: number) {
return score.toFixed(2);
}
type SharedProps = {|
+pnd: PagerankNodeDecomposition,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
+maxEntriesPerList: number,
|};
type PagerankTableProps = {|
+pnd: PagerankNodeDecomposition,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
+maxEntriesPerList: number,
|};
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
export class PagerankTable extends React.PureComponent<
PagerankTableProps,
PagerankTableState
> {
constructor() {
super();
this.state = {topLevelFilter: NodeAddress.empty};
}
render() {
return (
<div style={{marginTop: 10}}>
{this.renderFilterSelect()}
{this.renderTable()}
</div>
);
}
renderFilterSelect() {
const {pnd, adapters} = this.props;
if (pnd == null || adapters == null) {
throw new Error("Impossible.");
}
function optionGroup(adapter: DynamicPluginAdapter) {
const header = (
<option
key={adapter.static().nodePrefix()}
value={adapter.static().nodePrefix()}
style={{fontWeight: "bold"}}
>
{adapter.static().name()}
</option>
);
const entries = adapter
.static()
.nodeTypes()
.map((type) => (
<option key={type.prefix} value={type.prefix}>
{"\u2003" + type.name}
</option>
));
return [header, ...entries];
}
return (
<label>
<span>Filter by node type: </span>
<select
value={this.state.topLevelFilter}
onChange={(e) => {
this.setState({topLevelFilter: e.target.value});
}}
>
<option value={NodeAddress.empty}>Show all</option>
{sortBy(adapters, (a: DynamicPluginAdapter) => a.static().name()).map(
optionGroup
)}
</select>
</label>
);
}
renderTable() {
const {pnd, adapters, maxEntriesPerList} = this.props;
if (pnd == null || adapters == null || maxEntriesPerList == null) {
throw new Error("Impossible.");
}
const topLevelFilter = this.state.topLevelFilter;
const sharedProps = {pnd, adapters, maxEntriesPerList};
return (
<table
style={{
borderCollapse: "collapse",
marginTop: 10,
// If we don't subtract 1px here, then a horizontal scrollbar
// appears in Chrome (but not Firefox). I'm not sure why.
width: "calc(100% - 1px)",
}}
>
<thead>
<tr>
<th style={{textAlign: "left"}}>Description</th>
<th style={{textAlign: "right"}}>Connection</th>
<th style={{textAlign: "right"}}>Score</th>
</tr>
</thead>
<tbody>
<NodeRowList
sharedProps={sharedProps}
nodes={Array.from(pnd.keys()).filter((node) =>
NodeAddress.hasPrefix(node, topLevelFilter)
)}
/>
</tbody>
</table>
);
}
}
type NodeRowListProps = {|
+nodes: $ReadOnlyArray<NodeAddressT>,
+sharedProps: SharedProps,
|};
export class NodeRowList extends React.PureComponent<NodeRowListProps> {
render() {
const {nodes, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
return (
<React.Fragment>
{sortBy(nodes, (n) => -NullUtil.get(pnd.get(n)).score, (n) => n)
.slice(0, maxEntriesPerList)
.map((node) => (
<NodeRow node={node} key={node} sharedProps={sharedProps} />
))}
</React.Fragment>
);
}
}
type RowState = {|
expanded: boolean,
|};
type NodeRowProps = {|
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {node, sharedProps} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const {score} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
<tr key="self">
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{marginRight: 5}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<span>{nodeDescription(node, adapters)}</span>
</td>
<td style={{textAlign: "right"}}>{"—"}</td>
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
</tr>
{expanded && (
<ConnectionRowList
key="children"
depth={1}
node={node}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}
type ConnectionRowListProps = {|
+depth: number,
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class ConnectionRowList extends React.PureComponent<
ConnectionRowListProps
> {
render() {
const {depth, node, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
const {scoredConnections} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
{scoredConnections
.slice(0, maxEntriesPerList)
.map((sc) => (
<ConnectionRow
key={JSON.stringify(sc.connection.adjacency)}
depth={depth}
target={node}
scoredConnection={sc}
sharedProps={sharedProps}
/>
))}
</React.Fragment>
);
}
}
type ConnectionRowProps = {|
+depth: number,
+target: NodeAddressT,
+scoredConnection: ScoredConnection,
+sharedProps: SharedProps,
|};
export class ConnectionRow extends React.PureComponent<
ConnectionRowProps,
RowState
> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {
sharedProps,
target,
depth,
scoredConnection: {connection, source, sourceScore, connectionScore},
} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const {score: targetScore} = NullUtil.get(pnd.get(target));
const connectionProportion = connectionScore / targetScore;
const connectionPercent = (connectionProportion * 100).toFixed(2);
return (
<React.Fragment>
<tr
key="self"
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
>
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{
marginRight: 5,
marginLeft: 15 * depth,
}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<ConnectionView connection={connection} adapters={adapters} />
</td>
<td style={{textAlign: "right"}}>{connectionPercent}%</td>
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
</tr>
{expanded && (
<ConnectionRowList
key="children"
depth={depth + 1}
node={source}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}
export class ConnectionView extends React.PureComponent<{|
+connection: Connection,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|}> {
render() {
const {connection, adapters} = this.props;
function Badge({children}) {
return (
// The outer <span> acts as a strut to ensure that the badge
// takes up a full line height, even though its text is smaller.
<span>
<span
style={{
textTransform: "uppercase",
fontWeight: 700,
fontSize: "smaller",
}}
>
{children}
</span>
</span>
);
}
const {adjacency} = connection;
switch (adjacency.type) {
case "SYNTHETIC_LOOP":
return <Badge>synthetic loop</Badge>;
case "IN_EDGE":
return (
<span>
<Badge>
{edgeVerb(adjacency.edge.address, "BACKWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(adjacency.edge.src, adapters)}</span>
</span>
);
case "OUT_EDGE":
return (
<span>
<Badge>
{edgeVerb(adjacency.edge.address, "FORWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(adjacency.edge.dst, adapters)}</span>
</span>
);
default:
throw new Error((adjacency.type: empty));
}
}
}

View File

@ -1,607 +0,0 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import type {DynamicPluginAdapter} from "../pluginAdapter";
import {
PagerankTable,
NodeRowList,
NodeRow,
ConnectionRowList,
ConnectionRow,
ConnectionView,
} from "./PagerankTable";
import {pagerank} from "../../core/attribution/pagerank";
import sortBy from "lodash.sortby";
import {type Connection} from "../../core/attribution/graphToMarkovChain";
import * as NullUtil from "../../util/null";
import {
Graph,
type NodeAddressT,
NodeAddress,
EdgeAddress,
} from "../../core/graph";
require("../testUtil").configureEnzyme();
const COLUMNS = () => ["Description", "Connection", "Score"];
async function example() {
const graph = new Graph();
const nodes = {
fooAlpha: NodeAddress.fromParts(["foo", "a", "1"]),
fooBeta: NodeAddress.fromParts(["foo", "b", "2"]),
bar1: NodeAddress.fromParts(["bar", "a", "1"]),
bar2: NodeAddress.fromParts(["bar", "2"]),
xox: NodeAddress.fromParts(["xox"]),
empty: NodeAddress.empty,
};
Object.values(nodes).forEach((n) => graph.addNode((n: any)));
function addEdge(parts, src, dst) {
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
graph.addEdge(edge);
return edge;
}
const edges = {
fooA: addEdge(["foo", "a"], nodes.fooAlpha, nodes.fooBeta),
fooB: addEdge(["foo", "b"], nodes.fooAlpha, nodes.bar1),
fooC: addEdge(["foo", "c"], nodes.fooAlpha, nodes.xox),
barD: addEdge(["bar", "d"], nodes.bar1, nodes.bar1),
barE: addEdge(["bar", "e"], nodes.bar1, nodes.xox),
barF: addEdge(["bar", "f"], nodes.bar1, nodes.xox),
};
const adapters: DynamicPluginAdapter[] = [
{
static: () => ({
name: () => "foo",
nodePrefix: () => NodeAddress.fromParts(["foo"]),
edgePrefix: () => EdgeAddress.fromParts(["foo"]),
nodeTypes: () => [
{
name: "alpha",
prefix: NodeAddress.fromParts(["foo", "a"]),
defaultWeight: 1,
},
{
name: "beta",
prefix: NodeAddress.fromParts(["foo", "b"]),
defaultWeight: 1,
},
],
edgeTypes: () => [
{
prefix: EdgeAddress.fromParts(["foo"]),
forwardName: "foos",
backwardName: "is fooed by",
},
],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
},
{
static: () => ({
name: () => "bar",
nodePrefix: () => NodeAddress.fromParts(["bar"]),
edgePrefix: () => EdgeAddress.fromParts(["bar"]),
nodeTypes: () => [
{
name: "alpha",
prefix: NodeAddress.fromParts(["bar", "a"]),
defaultWeight: 1,
},
],
edgeTypes: () => [
{
prefix: EdgeAddress.fromParts(["bar"]),
forwardName: "bars",
backwardName: "is barred by",
},
],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
},
{
static: () => ({
name: () => "xox",
nodePrefix: () => NodeAddress.fromParts(["xox"]),
edgePrefix: () => EdgeAddress.fromParts(["xox"]),
nodeTypes: () => [],
edgeTypes: () => [],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (_unused_arg) => `xox node!`,
},
{
static: () => ({
nodePrefix: () => NodeAddress.fromParts(["unused"]),
edgePrefix: () => EdgeAddress.fromParts(["unused"]),
nodeTypes: () => [],
edgeTypes: () => [],
name: () => "unused",
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: () => {
throw new Error("Unused");
},
},
];
const pnd = await pagerank(graph, (_unused_Edge) => ({
toWeight: 1,
froWeight: 1,
}));
return {adapters, nodes, edges, graph, pnd};
}
describe("app/credExplorer/PagerankTable", () => {
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("PagerankTable", () => {
it("renders thead column order properly", async () => {
const {pnd, adapters} = await example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const th = element.find("thead th");
const columnNames = th.map((t) => t.text());
expect(columnNames).toEqual(COLUMNS());
});
describe("has a filter select", () => {
async function setup() {
const {pnd, adapters} = await example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const label = element.find("label");
const options = label.find("option");
return {pnd, adapters, element, label, options};
}
it("with expected label text", async () => {
const {label} = await setup();
const filterText = label
.find("span")
.first()
.text();
expect(filterText).toMatchSnapshot();
});
it("with expected option groups", async () => {
const {options} = await setup();
const optionsJSON = options.map((o) => ({
valueString: NodeAddress.toString(o.prop("value")),
style: o.prop("style"),
text: o.text(),
}));
expect(optionsJSON).toMatchSnapshot();
});
it("with the ability to filter nodes passed to NodeRowList", async () => {
const {element, options} = await setup();
const option1 = options.at(1);
const value = option1.prop("value");
expect(value).not.toEqual(NodeAddress.empty);
const previousNodes = element.find("NodeRowList").prop("nodes");
expect(
previousNodes.every((n) => NodeAddress.hasPrefix(n, value))
).toBe(false);
element.find("select").simulate("change", {target: {value}});
const actualNodes = element.find("NodeRowList").prop("nodes");
expect(actualNodes.every((n) => NodeAddress.hasPrefix(n, value))).toBe(
true
);
expect(actualNodes).not.toHaveLength(0);
});
});
describe("creates a NodeRowList", () => {
async function setup() {
const {adapters, pnd} = await example();
const maxEntriesPerList = 1;
const element = shallow(
<PagerankTable
pnd={pnd}
adapters={adapters}
maxEntriesPerList={maxEntriesPerList}
/>
);
const nrl = element.find("NodeRowList");
return {adapters, pnd, element, nrl, maxEntriesPerList};
}
it("with the correct SharedProps", async () => {
const {nrl, adapters, pnd, maxEntriesPerList} = await setup();
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
});
it("including all nodes by default", async () => {
const {nrl, pnd} = await setup();
const expectedNodes = Array.from(pnd.keys());
expect(nrl.prop("nodes")).toEqual(expectedNodes);
});
});
});
describe("NodeRowList", () => {
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
}
async function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd} = await example();
const nodes = sortedByScore(Array.from(pnd.keys()), pnd)
.reverse() // ascending order!
.filter((x) =>
NodeAddress.hasPrefix(x, NodeAddress.fromParts(["foo"]))
);
expect(nodes).not.toHaveLength(0);
expect(nodes).not.toHaveLength(1);
expect(nodes).not.toHaveLength(pnd.size);
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = <NodeRowList sharedProps={sharedProps} nodes={nodes} />;
const element = shallow(component);
return {element, adapters, sharedProps, nodes};
}
it("creates `NodeRow`s with the right props", async () => {
const {element, nodes, sharedProps} = await setup();
const rows = element.find("NodeRow");
expect(rows).toHaveLength(nodes.length);
const rowNodes = rows.map((row) => row.prop("node"));
// Check that we selected the right set of nodes. We'll check
// order in a separate test case.
expect(rowNodes.slice().sort()).toEqual(nodes.slice().sort());
rows.forEach((row) => {
expect(row.prop("sharedProps")).toEqual(sharedProps);
});
});
it("creates up to `maxEntriesPerList` `NodeRow`s", async () => {
const maxEntriesPerList = 1;
const {element, nodes, sharedProps} = await setup(maxEntriesPerList);
expect(nodes.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("NodeRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowNodes = rows.map((row) => row.prop("node"));
// Should have selected the right nodes.
expect(rowNodes).toEqual(
sortedByScore(nodes, sharedProps.pnd).slice(0, maxEntriesPerList)
);
});
it("sorts its children by score", async () => {
const {
element,
nodes,
sharedProps: {pnd},
} = await setup();
expect(nodes).not.toEqual(sortedByScore(nodes, pnd));
const rows = element.find("NodeRow");
const rowNodes = rows.map((row) => row.prop("node"));
expect(rowNodes).toEqual(sortedByScore(rowNodes, pnd));
});
});
describe("NodeRow", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const node = nodes.bar1;
const component = <NodeRow node={node} sharedProps={sharedProps} />;
const element = shallow(component);
return {element, node, sharedProps};
}
it("renders the right number of columns", async () => {
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
});
it("renders the node description", async () => {
const {element} = await setup();
const expectedDescription = 'bar: NodeAddress["bar","a","1"]';
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(descriptionColumn)
.find("span")
.text()
).toEqual(expectedDescription);
});
it("renders an empty connection column", async () => {
const {element} = await setup();
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual("—");
});
it("renders a score column with the node's score", async () => {
const {element, sharedProps, node} = await setup();
const {score} = NullUtil.get(sharedProps.pnd.get(node));
const expectedScore = score.toFixed(2);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, sharedProps, node} = await setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(1);
expect(crl.prop("node")).toBe(node);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
describe("ConnectionRowList", () => {
async function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd, nodes} = await example();
const depth = 2;
const node = nodes.bar1;
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = (
<ConnectionRowList
depth={depth}
node={node}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, node, sharedProps};
}
it("creates `ConnectionRow`s with the right props", async () => {
const {element, depth, node, sharedProps} = await setup();
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(connections.length);
const rowPropses = rows.map((row) => row.props());
// Order should be the same as the order in the decomposition.
expect(rowPropses).toEqual(
connections.map((sc) => ({
depth,
sharedProps,
target: node,
scoredConnection: sc,
}))
);
});
it("limits the number of rows by `maxEntriesPerList`", async () => {
const maxEntriesPerList = 1;
const {element, node, sharedProps} = await setup(maxEntriesPerList);
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
expect(connections.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowConnections = rows.map((row) => row.prop("scoredConnection"));
// Should have selected the right nodes.
expect(rowConnections).toEqual(connections.slice(0, maxEntriesPerList));
});
});
describe("ConnectionRow", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const target = nodes.bar1;
const {scoredConnections} = NullUtil.get(pnd.get(target));
const alphaConnections = scoredConnections.filter(
(sc) => sc.source === nodes.fooAlpha
);
expect(alphaConnections).toHaveLength(1);
const connection = alphaConnections[0];
const {source} = connection;
const depth = 2;
const component = (
<ConnectionRow
depth={depth}
target={target}
scoredConnection={connection}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, target, source, connection, sharedProps};
}
it("renders the right number of columns", async () => {
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
});
it("has proper depth-based styling", async () => {
const {element} = await setup();
expect({
buttonStyle: element.find("button").prop("style"),
trStyle: element.find("tr").prop("style"),
}).toMatchSnapshot();
});
it("renders the source view", async () => {
const {element, sharedProps, connection} = await setup();
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
const view = element
.find("td")
.at(descriptionColumn)
.find("ConnectionView");
expect(view).toHaveLength(1);
expect(view.props()).toEqual({
adapters: sharedProps.adapters,
connection: connection.connection,
});
});
it("renders the connection percentage", async () => {
const {element, connection, sharedProps, target} = await setup();
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
const proportion =
connection.connectionScore /
NullUtil.get(sharedProps.pnd.get(target)).score;
expect(proportion).toBeGreaterThan(0.0);
expect(proportion).toBeLessThan(1.0);
const expectedText = (proportion * 100).toFixed(2) + "%";
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedText);
});
it("renders a score column with the source's score", async () => {
const {element, connection} = await setup();
const expectedScore = connection.sourceScore.toFixed(2);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, depth, sharedProps, source} = await setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl).not.toHaveLength(0);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(depth + 1);
expect(crl.prop("node")).toBe(source);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
describe("ConnectionView", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const {scoredConnections} = NullUtil.get(pnd.get(nodes.bar1));
const connections = scoredConnections.map((sc) => sc.connection);
function connectionByType(t) {
return NullUtil.get(
connections.filter((c) => c.adjacency.type === t)[0],
`Couldn't find connection for type ${t}`
);
}
const inConnection = connectionByType("IN_EDGE");
const outConnection = connectionByType("OUT_EDGE");
const syntheticConnection = connectionByType("SYNTHETIC_LOOP");
function cvForConnection(connection: Connection) {
return shallow(
<ConnectionView adapters={adapters} connection={connection} />
);
}
return {
adapters,
connections,
pnd,
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
};
}
it("always renders exactly one `Badge`", async () => {
const {
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
} = await setup();
for (const connection of [
syntheticConnection,
inConnection,
outConnection,
]) {
expect(cvForConnection(connection).find("Badge")).toHaveLength(1);
}
});
it("for inward connections, renders a `Badge` and description", async () => {
const {cvForConnection, inConnection} = await setup();
const view = cvForConnection(inConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("is barred by");
expect(description.text()).toEqual('bar: NodeAddress["bar","a","1"]');
});
it("for outward connections, renders a `Badge` and description", async () => {
const {cvForConnection, outConnection} = await setup();
const view = cvForConnection(outConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("bars");
expect(description.text()).toEqual("xox node!");
});
it("for synthetic connections, renders only a `Badge`", async () => {
const {cvForConnection, syntheticConnection} = await setup();
const view = cvForConnection(syntheticConnection);
expect(view.find("span")).toHaveLength(0);
expect(
view
.find("Badge")
.children()
.text()
).toEqual("synthetic loop");
});
});
});

View File

@ -0,0 +1,165 @@
// @flow
import React from "react";
import * as NullUtil from "../../../util/null";
import type {NodeAddressT} from "../../../core/graph";
import type {Connection} from "../../../core/attribution/graphToMarkovChain";
import type {ScoredConnection} from "../../../core/attribution/pagerankNodeDecomposition";
import type {DynamicPluginAdapter} from "../../pluginAdapter";
import {
edgeVerb,
nodeDescription,
scoreDisplay,
type SharedProps,
type RowState,
} from "./shared";
type ConnectionRowListProps = {|
+depth: number,
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class ConnectionRowList extends React.PureComponent<
ConnectionRowListProps
> {
render() {
const {depth, node, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
const {scoredConnections} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
{scoredConnections
.slice(0, maxEntriesPerList)
.map((sc) => (
<ConnectionRow
key={JSON.stringify(sc.connection.adjacency)}
depth={depth}
target={node}
scoredConnection={sc}
sharedProps={sharedProps}
/>
))}
</React.Fragment>
);
}
}
type ConnectionRowProps = {|
+depth: number,
+target: NodeAddressT,
+scoredConnection: ScoredConnection,
+sharedProps: SharedProps,
|};
export class ConnectionRow extends React.PureComponent<
ConnectionRowProps,
RowState
> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {
sharedProps,
target,
depth,
scoredConnection: {connection, source, sourceScore, connectionScore},
} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const {score: targetScore} = NullUtil.get(pnd.get(target));
const connectionProportion = connectionScore / targetScore;
const connectionPercent = (connectionProportion * 100).toFixed(2);
return (
<React.Fragment>
<tr
key="self"
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
>
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{
marginRight: 5,
marginLeft: 15 * depth,
}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<ConnectionView connection={connection} adapters={adapters} />
</td>
<td style={{textAlign: "right"}}>{connectionPercent}%</td>
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
</tr>
{expanded && (
<ConnectionRowList
key="children"
depth={depth + 1}
node={source}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}
export class ConnectionView extends React.PureComponent<{|
+connection: Connection,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
|}> {
render() {
const {connection, adapters} = this.props;
function Badge({children}) {
return (
// The outer <span> acts as a strut to ensure that the badge
// takes up a full line height, even though its text is smaller.
<span>
<span
style={{
textTransform: "uppercase",
fontWeight: 700,
fontSize: "smaller",
}}
>
{children}
</span>
</span>
);
}
const {adjacency} = connection;
switch (adjacency.type) {
case "SYNTHETIC_LOOP":
return <Badge>synthetic loop</Badge>;
case "IN_EDGE":
return (
<span>
<Badge>
{edgeVerb(adjacency.edge.address, "BACKWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(adjacency.edge.src, adapters)}</span>
</span>
);
case "OUT_EDGE":
return (
<span>
<Badge>
{edgeVerb(adjacency.edge.address, "FORWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(adjacency.edge.dst, adapters)}</span>
</span>
);
default:
throw new Error((adjacency.type: empty));
}
}
}

View File

@ -0,0 +1,245 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import * as NullUtil from "../../../util/null";
import type {Connection} from "../../../core/attribution/graphToMarkovChain";
import {ConnectionRowList, ConnectionRow, ConnectionView} from "./Connection";
import {example, COLUMNS} from "./sharedTestUtils";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/pagerankTable/Connection", () => {
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("ConnectionRowList", () => {
async function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd, nodes} = await example();
const depth = 2;
const node = nodes.bar1;
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = (
<ConnectionRowList
depth={depth}
node={node}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, node, sharedProps};
}
it("creates `ConnectionRow`s with the right props", async () => {
const {element, depth, node, sharedProps} = await setup();
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(connections.length);
const rowPropses = rows.map((row) => row.props());
// Order should be the same as the order in the decomposition.
expect(rowPropses).toEqual(
connections.map((sc) => ({
depth,
sharedProps,
target: node,
scoredConnection: sc,
}))
);
});
it("limits the number of rows by `maxEntriesPerList`", async () => {
const maxEntriesPerList = 1;
const {element, node, sharedProps} = await setup(maxEntriesPerList);
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
expect(connections.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("ConnectionRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowConnections = rows.map((row) => row.prop("scoredConnection"));
// Should have selected the right nodes.
expect(rowConnections).toEqual(connections.slice(0, maxEntriesPerList));
});
});
describe("ConnectionRow", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const target = nodes.bar1;
const {scoredConnections} = NullUtil.get(pnd.get(target));
const alphaConnections = scoredConnections.filter(
(sc) => sc.source === nodes.fooAlpha
);
expect(alphaConnections).toHaveLength(1);
const connection = alphaConnections[0];
const {source} = connection;
const depth = 2;
const component = (
<ConnectionRow
depth={depth}
target={target}
scoredConnection={connection}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, target, source, connection, sharedProps};
}
it("renders the right number of columns", async () => {
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
});
it("has proper depth-based styling", async () => {
const {element} = await setup();
expect({
buttonStyle: element.find("button").prop("style"),
trStyle: element.find("tr").prop("style"),
}).toMatchSnapshot();
});
it("renders the source view", async () => {
const {element, sharedProps, connection} = await setup();
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
const view = element
.find("td")
.at(descriptionColumn)
.find("ConnectionView");
expect(view).toHaveLength(1);
expect(view.props()).toEqual({
adapters: sharedProps.adapters,
connection: connection.connection,
});
});
it("renders the connection percentage", async () => {
const {element, connection, sharedProps, target} = await setup();
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
const proportion =
connection.connectionScore /
NullUtil.get(sharedProps.pnd.get(target)).score;
expect(proportion).toBeGreaterThan(0.0);
expect(proportion).toBeLessThan(1.0);
const expectedText = (proportion * 100).toFixed(2) + "%";
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedText);
});
it("renders a score column with the source's score", async () => {
const {element, connection} = await setup();
const expectedScore = connection.sourceScore.toFixed(2);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, depth, sharedProps, source} = await setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl).not.toHaveLength(0);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(depth + 1);
expect(crl.prop("node")).toBe(source);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
describe("ConnectionView", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const {scoredConnections} = NullUtil.get(pnd.get(nodes.bar1));
const connections = scoredConnections.map((sc) => sc.connection);
function connectionByType(t) {
return NullUtil.get(
connections.filter((c) => c.adjacency.type === t)[0],
`Couldn't find connection for type ${t}`
);
}
const inConnection = connectionByType("IN_EDGE");
const outConnection = connectionByType("OUT_EDGE");
const syntheticConnection = connectionByType("SYNTHETIC_LOOP");
function cvForConnection(connection: Connection) {
return shallow(
<ConnectionView adapters={adapters} connection={connection} />
);
}
return {
adapters,
connections,
pnd,
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
};
}
it("always renders exactly one `Badge`", async () => {
const {
cvForConnection,
inConnection,
outConnection,
syntheticConnection,
} = await setup();
for (const connection of [
syntheticConnection,
inConnection,
outConnection,
]) {
expect(cvForConnection(connection).find("Badge")).toHaveLength(1);
}
});
it("for inward connections, renders a `Badge` and description", async () => {
const {cvForConnection, inConnection} = await setup();
const view = cvForConnection(inConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("is barred by");
expect(description.text()).toEqual('bar: NodeAddress["bar","a","1"]');
});
it("for outward connections, renders a `Badge` and description", async () => {
const {cvForConnection, outConnection} = await setup();
const view = cvForConnection(outConnection);
const outerSpan = view.find("span").first();
const badge = outerSpan.find("Badge");
const description = outerSpan.children().find("span");
expect(badge.children().text()).toEqual("bars");
expect(description.text()).toEqual("xox node!");
});
it("for synthetic connections, renders only a `Badge`", async () => {
const {cvForConnection, syntheticConnection} = await setup();
const view = cvForConnection(syntheticConnection);
expect(view.find("span")).toHaveLength(0);
expect(
view
.find("Badge")
.children()
.text()
).toEqual("synthetic loop");
});
});
});

View File

@ -0,0 +1,84 @@
// @flow
import React from "react";
import sortBy from "lodash.sortby";
import * as NullUtil from "../../../util/null";
import {type NodeAddressT} from "../../../core/graph";
import {
nodeDescription,
scoreDisplay,
type SharedProps,
type RowState,
} from "./shared";
import {ConnectionRowList} from "./Connection";
type NodeRowListProps = {|
+nodes: $ReadOnlyArray<NodeAddressT>,
+sharedProps: SharedProps,
|};
export class NodeRowList extends React.PureComponent<NodeRowListProps> {
render() {
const {nodes, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
return (
<React.Fragment>
{sortBy(nodes, (n) => -NullUtil.get(pnd.get(n)).score, (n) => n)
.slice(0, maxEntriesPerList)
.map((node) => (
<NodeRow node={node} key={node} sharedProps={sharedProps} />
))}
</React.Fragment>
);
}
}
type NodeRowProps = {|
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {node, sharedProps} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const {score} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
<tr key="self">
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{marginRight: 5}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<span>{nodeDescription(node, adapters)}</span>
</td>
<td style={{textAlign: "right"}}>{"—"}</td>
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
</tr>
{expanded && (
<ConnectionRowList
key="children"
depth={1}
node={node}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}

View File

@ -0,0 +1,152 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import sortBy from "lodash.sortby";
import * as NullUtil from "../../../util/null";
import {type NodeAddressT, NodeAddress} from "../../../core/graph";
import {example, COLUMNS} from "./sharedTestUtils";
import {NodeRowList, NodeRow} from "./Node";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/pagerankTable/Node", () => {
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("NodeRowList", () => {
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
}
async function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd} = await example();
const nodes = sortedByScore(Array.from(pnd.keys()), pnd)
.reverse() // ascending order!
.filter((x) =>
NodeAddress.hasPrefix(x, NodeAddress.fromParts(["foo"]))
);
expect(nodes).not.toHaveLength(0);
expect(nodes).not.toHaveLength(1);
expect(nodes).not.toHaveLength(pnd.size);
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = <NodeRowList sharedProps={sharedProps} nodes={nodes} />;
const element = shallow(component);
return {element, adapters, sharedProps, nodes};
}
it("creates `NodeRow`s with the right props", async () => {
const {element, nodes, sharedProps} = await setup();
const rows = element.find("NodeRow");
expect(rows).toHaveLength(nodes.length);
const rowNodes = rows.map((row) => row.prop("node"));
// Check that we selected the right set of nodes. We'll check
// order in a separate test case.
expect(rowNodes.slice().sort()).toEqual(nodes.slice().sort());
rows.forEach((row) => {
expect(row.prop("sharedProps")).toEqual(sharedProps);
});
});
it("creates up to `maxEntriesPerList` `NodeRow`s", async () => {
const maxEntriesPerList = 1;
const {element, nodes, sharedProps} = await setup(maxEntriesPerList);
expect(nodes.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("NodeRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowNodes = rows.map((row) => row.prop("node"));
// Should have selected the right nodes.
expect(rowNodes).toEqual(
sortedByScore(nodes, sharedProps.pnd).slice(0, maxEntriesPerList)
);
});
it("sorts its children by score", async () => {
const {
element,
nodes,
sharedProps: {pnd},
} = await setup();
expect(nodes).not.toEqual(sortedByScore(nodes, pnd));
const rows = element.find("NodeRow");
const rowNodes = rows.map((row) => row.prop("node"));
expect(rowNodes).toEqual(sortedByScore(rowNodes, pnd));
});
});
describe("NodeRow", () => {
async function setup() {
const {pnd, adapters, nodes} = await example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const node = nodes.bar1;
const component = <NodeRow node={node} sharedProps={sharedProps} />;
const element = shallow(component);
return {element, node, sharedProps};
}
it("renders the right number of columns", async () => {
expect((await setup()).element.find("td")).toHaveLength(COLUMNS().length);
});
it("renders the node description", async () => {
const {element} = await setup();
const expectedDescription = 'bar: NodeAddress["bar","a","1"]';
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(descriptionColumn)
.find("span")
.text()
).toEqual(expectedDescription);
});
it("renders an empty connection column", async () => {
const {element} = await setup();
const connectionColumn = COLUMNS().indexOf("Connection");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual("—");
});
it("renders a score column with the node's score", async () => {
const {element, sharedProps, node} = await setup();
const {score} = NullUtil.get(sharedProps.pnd.get(node));
const expectedScore = score.toFixed(2);
const connectionColumn = COLUMNS().indexOf("Score");
expect(connectionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(connectionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", async () => {
const {element} = await setup();
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
it('has a working "expand" button', async () => {
const {element, sharedProps, node} = await setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ConnectionRowList");
expect(crl).toHaveLength(1);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(1);
expect(crl.prop("node")).toBe(node);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ConnectionRowList")).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,115 @@
// @flow
import React from "react";
import sortBy from "lodash.sortby";
import {type NodeAddressT, NodeAddress} from "../../../core/graph";
import type {PagerankNodeDecomposition} from "../../../core/attribution/pagerankNodeDecomposition";
import type {DynamicPluginAdapter} from "../../pluginAdapter";
import {NodeRowList} from "./Node";
type PagerankTableProps = {|
+pnd: PagerankNodeDecomposition,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
+maxEntriesPerList: number,
|};
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
export class PagerankTable extends React.PureComponent<
PagerankTableProps,
PagerankTableState
> {
constructor() {
super();
this.state = {topLevelFilter: NodeAddress.empty};
}
render() {
return (
<div style={{marginTop: 10}}>
{this.renderFilterSelect()}
{this.renderTable()}
</div>
);
}
renderFilterSelect() {
const {pnd, adapters} = this.props;
if (pnd == null || adapters == null) {
throw new Error("Impossible.");
}
function optionGroup(adapter: DynamicPluginAdapter) {
const header = (
<option
key={adapter.static().nodePrefix()}
value={adapter.static().nodePrefix()}
style={{fontWeight: "bold"}}
>
{adapter.static().name()}
</option>
);
const entries = adapter
.static()
.nodeTypes()
.map((type) => (
<option key={type.prefix} value={type.prefix}>
{"\u2003" + type.name}
</option>
));
return [header, ...entries];
}
return (
<label>
<span>Filter by node type: </span>
<select
value={this.state.topLevelFilter}
onChange={(e) => {
this.setState({topLevelFilter: e.target.value});
}}
>
<option value={NodeAddress.empty}>Show all</option>
{sortBy(adapters, (a: DynamicPluginAdapter) => a.static().name()).map(
optionGroup
)}
</select>
</label>
);
}
renderTable() {
const {pnd, adapters, maxEntriesPerList} = this.props;
if (pnd == null || adapters == null || maxEntriesPerList == null) {
throw new Error("Impossible.");
}
const topLevelFilter = this.state.topLevelFilter;
const sharedProps = {pnd, adapters, maxEntriesPerList};
return (
<table
style={{
borderCollapse: "collapse",
marginTop: 10,
// If we don't subtract 1px here, then a horizontal scrollbar
// appears in Chrome (but not Firefox). I'm not sure why.
width: "calc(100% - 1px)",
}}
>
<thead>
<tr>
<th style={{textAlign: "left"}}>Description</th>
<th style={{textAlign: "right"}}>Connection</th>
<th style={{textAlign: "right"}}>Score</th>
</tr>
</thead>
<tbody>
<NodeRowList
sharedProps={sharedProps}
nodes={Array.from(pnd.keys()).filter((node) =>
NodeAddress.hasPrefix(node, topLevelFilter)
)}
/>
</tbody>
</table>
);
}
}

View File

@ -0,0 +1,105 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import {NodeAddress} from "../../../core/graph";
import {PagerankTable} from "./Table";
import {example, COLUMNS} from "./sharedTestUtils";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/pagerankTable/Table", () => {
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("PagerankTable", () => {
it("renders thead column order properly", async () => {
const {pnd, adapters} = await example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const th = element.find("thead th");
const columnNames = th.map((t) => t.text());
expect(columnNames).toEqual(COLUMNS());
});
describe("has a filter select", () => {
async function setup() {
const {pnd, adapters} = await example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const label = element.find("label");
const options = label.find("option");
return {pnd, adapters, element, label, options};
}
it("with expected label text", async () => {
const {label} = await setup();
const filterText = label
.find("span")
.first()
.text();
expect(filterText).toMatchSnapshot();
});
it("with expected option groups", async () => {
const {options} = await setup();
const optionsJSON = options.map((o) => ({
valueString: NodeAddress.toString(o.prop("value")),
style: o.prop("style"),
text: o.text(),
}));
expect(optionsJSON).toMatchSnapshot();
});
it("with the ability to filter nodes passed to NodeRowList", async () => {
const {element, options} = await setup();
const option1 = options.at(1);
const value = option1.prop("value");
expect(value).not.toEqual(NodeAddress.empty);
const previousNodes = element.find("NodeRowList").prop("nodes");
expect(
previousNodes.every((n) => NodeAddress.hasPrefix(n, value))
).toBe(false);
element.find("select").simulate("change", {target: {value}});
const actualNodes = element.find("NodeRowList").prop("nodes");
expect(actualNodes.every((n) => NodeAddress.hasPrefix(n, value))).toBe(
true
);
expect(actualNodes).not.toHaveLength(0);
});
});
describe("creates a NodeRowList", () => {
async function setup() {
const {adapters, pnd} = await example();
const maxEntriesPerList = 1;
const element = shallow(
<PagerankTable
pnd={pnd}
adapters={adapters}
maxEntriesPerList={maxEntriesPerList}
/>
);
const nrl = element.find("NodeRowList");
return {adapters, pnd, element, nrl, maxEntriesPerList};
}
it("with the correct SharedProps", async () => {
const {nrl, adapters, pnd, maxEntriesPerList} = await setup();
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
});
it("including all nodes by default", async () => {
const {nrl, pnd} = await setup();
const expectedNodes = Array.from(pnd.keys());
expect(nrl.prop("nodes")).toEqual(expectedNodes);
});
});
});
});

View File

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/pagerankTable/Connection ConnectionRow has proper depth-based styling 1`] = `
Object {
"buttonStyle": Object {
"marginLeft": 30,
"marginRight": 5,
},
"trStyle": Object {
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
},
}
`;

View File

@ -1,20 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/PagerankTable ConnectionRow has proper depth-based styling 1`] = `
Object {
"buttonStyle": Object {
"marginLeft": 30,
"marginRight": 5,
},
"trStyle": Object {
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
},
}
`;
exports[`app/credExplorer/pagerankTable/Table PagerankTable has a filter select with expected label text 1`] = `"Filter by node type: "`;
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected label text 1`] = `"Filter by node type: "`;
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected option groups 1`] = `
exports[`app/credExplorer/pagerankTable/Table PagerankTable has a filter select with expected option groups 1`] = `
Array [
Object {
"style": undefined,

View File

@ -0,0 +1,54 @@
// @flow
import {
type EdgeAddressT,
type NodeAddressT,
NodeAddress,
} from "../../../core/graph";
import type {PagerankNodeDecomposition} from "../../../core/attribution/pagerankNodeDecomposition";
import {
type DynamicPluginAdapter,
dynamicDispatchByNode,
dynamicDispatchByEdge,
findEdgeType,
} from "../../pluginAdapter";
export function nodeDescription(
address: NodeAddressT,
adapters: $ReadOnlyArray<DynamicPluginAdapter>
): string {
const adapter = dynamicDispatchByNode(adapters, address);
try {
return adapter.nodeDescription(address);
} catch (e) {
const result = NodeAddress.toString(address);
console.error(`Error getting description for ${result}: ${e.message}`);
return result;
}
}
export function edgeVerb(
address: EdgeAddressT,
direction: "FORWARD" | "BACKWARD",
adapters: $ReadOnlyArray<DynamicPluginAdapter>
): string {
const adapter = dynamicDispatchByEdge(adapters, address);
const edgeType = findEdgeType(adapter.static(), address);
return direction === "FORWARD" ? edgeType.forwardName : edgeType.backwardName;
}
export function scoreDisplay(score: number) {
return score.toFixed(2);
}
export type SharedProps = {|
+pnd: PagerankNodeDecomposition,
+adapters: $ReadOnlyArray<DynamicPluginAdapter>,
+maxEntriesPerList: number,
|};
export type RowState = {|
expanded: boolean,
|};

View File

@ -0,0 +1,141 @@
// @flow
import {Graph, NodeAddress, EdgeAddress} from "../../../core/graph";
import type {DynamicPluginAdapter} from "../../pluginAdapter";
import {pagerank} from "../../../core/attribution/pagerank";
export const COLUMNS = () => ["Description", "Connection", "Score"];
export async function example() {
const graph = new Graph();
const nodes = {
fooAlpha: NodeAddress.fromParts(["foo", "a", "1"]),
fooBeta: NodeAddress.fromParts(["foo", "b", "2"]),
bar1: NodeAddress.fromParts(["bar", "a", "1"]),
bar2: NodeAddress.fromParts(["bar", "2"]),
xox: NodeAddress.fromParts(["xox"]),
empty: NodeAddress.empty,
};
Object.values(nodes).forEach((n) => graph.addNode((n: any)));
function addEdge(parts, src, dst) {
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
graph.addEdge(edge);
return edge;
}
const edges = {
fooA: addEdge(["foo", "a"], nodes.fooAlpha, nodes.fooBeta),
fooB: addEdge(["foo", "b"], nodes.fooAlpha, nodes.bar1),
fooC: addEdge(["foo", "c"], nodes.fooAlpha, nodes.xox),
barD: addEdge(["bar", "d"], nodes.bar1, nodes.bar1),
barE: addEdge(["bar", "e"], nodes.bar1, nodes.xox),
barF: addEdge(["bar", "f"], nodes.bar1, nodes.xox),
};
const adapters: DynamicPluginAdapter[] = [
{
static: () => ({
name: () => "foo",
nodePrefix: () => NodeAddress.fromParts(["foo"]),
edgePrefix: () => EdgeAddress.fromParts(["foo"]),
nodeTypes: () => [
{
name: "alpha",
prefix: NodeAddress.fromParts(["foo", "a"]),
defaultWeight: 1,
},
{
name: "beta",
prefix: NodeAddress.fromParts(["foo", "b"]),
defaultWeight: 1,
},
],
edgeTypes: () => [
{
prefix: EdgeAddress.fromParts(["foo"]),
forwardName: "foos",
backwardName: "is fooed by",
},
],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
},
{
static: () => ({
name: () => "bar",
nodePrefix: () => NodeAddress.fromParts(["bar"]),
edgePrefix: () => EdgeAddress.fromParts(["bar"]),
nodeTypes: () => [
{
name: "alpha",
prefix: NodeAddress.fromParts(["bar", "a"]),
defaultWeight: 1,
},
],
edgeTypes: () => [
{
prefix: EdgeAddress.fromParts(["bar"]),
forwardName: "bars",
backwardName: "is barred by",
},
],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
},
{
static: () => ({
name: () => "xox",
nodePrefix: () => NodeAddress.fromParts(["xox"]),
edgePrefix: () => EdgeAddress.fromParts(["xox"]),
nodeTypes: () => [],
edgeTypes: () => [],
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: (_unused_arg) => `xox node!`,
},
{
static: () => ({
nodePrefix: () => NodeAddress.fromParts(["unused"]),
edgePrefix: () => EdgeAddress.fromParts(["unused"]),
nodeTypes: () => [],
edgeTypes: () => [],
name: () => "unused",
load: (_unused_repo) => {
throw new Error("unused");
},
}),
graph: () => {
throw new Error("unused");
},
nodeDescription: () => {
throw new Error("Unused");
},
},
];
const pnd = await pagerank(graph, (_unused_Edge) => ({
toWeight: 1,
froWeight: 1,
}));
return {adapters, nodes, edges, graph, pnd};
}