PagerankTable displays aggregated connections

Previously, expanding a node would display the individual connections
that contributed cred to that node. For nodes with high degree, this was
a pretty noisy UI.

Now, expanding a node displays "aggregations": for every type of
adjacent connection (where type is the union of the edge type and the
adjacent node type), we show a summary of the total cred from
connections of that type. The result is a much more managable summary
view. Naturally, these aggregations can be further expanded to see the
individual connections.

Closes #502.

Test plan: The new behavior is unit tested. You can also launch the cred
explorer and experience the UI directly. I have used the new UI a lot,
as well as demo'd it to people, and I like it quite a bit.
This commit is contained in:
Dandelion Mané 2018-08-13 20:43:06 -07:00
parent 8df0056f08
commit 094582be32
7 changed files with 367 additions and 14 deletions

View File

@ -2,4 +2,5 @@
## [Unreleased] ## [Unreleased]
- Start tracking changes in `CHANGELOG.md` - Start tracking changes in `CHANGELOG.md`
- Aggregate over connection types in the cred explorer (#502)

View File

@ -0,0 +1,111 @@
// @flow
import React from "react";
import * as NullUtil from "../../../util/null";
import type {NodeAddressT} from "../../../core/graph";
import {ConnectionRowList} from "./Connection";
import {aggregateFlat, type FlatAggregation, aggregationKey} from "./aggregate";
import {Badge, type SharedProps} from "./shared";
import {TableRow} from "./TableRow";
type AggregationRowListProps = {|
+depth: number,
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class AggregationRowList extends React.PureComponent<
AggregationRowListProps
> {
render() {
const {depth, node, sharedProps} = this.props;
const {pnd, adapters} = sharedProps;
const {scoredConnections} = NullUtil.get(pnd.get(node));
const aggregations = aggregateFlat(
scoredConnections,
adapters.static().nodeTypes(),
adapters.static().edgeTypes()
);
return (
<React.Fragment>
{aggregations.map((agg) => (
<AggregationRow
key={aggregationKey(agg)}
depth={depth}
target={node}
sharedProps={sharedProps}
aggregation={agg}
/>
))}
</React.Fragment>
);
}
}
type AggregationRowProps = {|
+depth: number,
+target: NodeAddressT,
+aggregation: FlatAggregation,
+sharedProps: SharedProps,
|};
export class AggregationRow extends React.PureComponent<AggregationRowProps> {
render() {
const {sharedProps, target, depth, aggregation} = this.props;
const {pnd} = sharedProps;
const score = aggregation.summary.score;
const {score: targetScore} = NullUtil.get(pnd.get(target));
const connectionProportion = score / targetScore;
return (
<TableRow
depth={depth}
indent={1}
showPadding={false}
connectionProportion={connectionProportion}
score={score}
description={<AggregationView aggregation={aggregation} />}
>
<ConnectionRowList
key="children"
depth={depth}
node={target}
connections={aggregation.connections}
sharedProps={sharedProps}
/>
</TableRow>
);
}
}
export class AggregationView extends React.PureComponent<{|
+aggregation: FlatAggregation,
|}> {
render() {
const {aggregation} = this.props;
const {connectionType, summary, nodeType} = aggregation;
function connectionDescription() {
switch (connectionType.type) {
case "SYNTHETIC_LOOP":
return "synthetic loop";
case "IN_EDGE":
return connectionType.edgeType.backwardName;
case "OUT_EDGE":
return connectionType.edgeType.forwardName;
default:
throw new Error((connectionType.type: empty));
}
}
const nodeName = summary.size === 1 ? nodeType.name : nodeType.pluralName;
return (
<span>
<Badge>{connectionDescription()}</Badge>
<span> {summary.size} </span>
<span style={{textTransform: "lowercase"}}>{nodeName}</span>
</span>
);
}
}

View File

@ -0,0 +1,238 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import * as NullUtil from "../../../util/null";
import {NodeAddress, EdgeAddress} from "../../../core/graph";
import type {NodeType, EdgeType} from "../../adapters/pluginAdapter";
import {
AggregationRowList,
AggregationRow,
AggregationView,
} from "./Aggregation";
import {ConnectionRowList} from "./Connection";
import {Badge} from "./shared";
import {example} from "./sharedTestUtils";
import {aggregateFlat, type FlatAggregation} from "./aggregate";
import {TableRow} from "./TableRow";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/pagerankTable/Aggregation", () => {
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("AggregationRowList", () => {
it("instantiates AggregationRows for each aggregation", async () => {
const {adapters, pnd, nodes} = await example();
const node = nodes.bar1;
const depth = 20;
const maxEntriesPerList = 50;
const sharedProps = {adapters, pnd, maxEntriesPerList};
const connections = NullUtil.get(pnd.get(node)).scoredConnections;
const aggregations = aggregateFlat(
connections,
adapters.static().nodeTypes(),
adapters.static().edgeTypes()
);
const el = shallow(
<AggregationRowList
depth={depth}
node={node}
sharedProps={sharedProps}
/>
);
const aggregationRows = el.children(AggregationRow);
expect(aggregationRows).toHaveLength(aggregations.length);
for (let i = 0; i < aggregations.length; i++) {
const aggregationRow = aggregationRows.at(i);
const props = aggregationRow.props();
expect(props.depth).toEqual(depth);
expect(props.target).toEqual(node);
expect(props.sharedProps).toEqual(sharedProps);
expect(props.aggregation).toEqual(aggregations[i]);
i++;
}
});
});
describe("AggregationRow", () => {
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 aggregations = aggregateFlat(
scoredConnections,
adapters.static().nodeTypes(),
adapters.static().edgeTypes()
);
const aggregation = aggregations[0];
const depth = 23;
const component = (
<AggregationRow
depth={depth}
target={target}
aggregation={aggregation}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
const row = element.find(TableRow);
return {
element,
row,
depth,
target,
aggregation,
sharedProps,
};
}
describe("instantiates a TableRow", () => {
it("with the correct depth", async () => {
const {row, depth} = await setup();
expect(row.props().depth).toBe(depth);
});
it("with indent=1", async () => {
const {row} = await setup();
expect(row.props().indent).toBe(1);
});
it("with showPadding=false", async () => {
const {row} = await setup();
expect(row.props().showPadding).toBe(false);
});
it("with the aggregation score", async () => {
const {row, aggregation} = await setup();
expect(row.props().score).toBe(aggregation.summary.score);
});
it("with the aggregation's contribution proportion", async () => {
const {row, target, aggregation, sharedProps} = await setup();
const targetScore = NullUtil.get(sharedProps.pnd.get(target)).score;
expect(row.props().connectionProportion).toBe(
aggregation.summary.score / targetScore
);
});
it("with a AggregationView as description", async () => {
const {row, aggregation} = await setup();
const description = row.props().description;
const cv = shallow(description).instance();
expect(cv).toBeInstanceOf(AggregationView);
expect(cv.props.aggregation).toEqual(aggregation);
});
describe("with a ConnectionRowList as children", () => {
function getChildren(row) {
const children = row.props().children;
return shallow(children).instance();
}
it("which is a ConnectionRowList", async () => {
const {row} = await setup();
expect(getChildren(row)).toBeInstanceOf(ConnectionRowList);
});
it("which has the same depth", async () => {
const {row, depth} = await setup();
expect(getChildren(row).props.depth).toBe(depth);
});
it("which has the aggregation target as its node target", async () => {
const {row, target} = await setup();
expect(getChildren(row).props.node).toBe(target);
});
it("which has the right sharedProps", async () => {
const {row, sharedProps} = await setup();
expect(getChildren(row).props.sharedProps).toBe(sharedProps);
});
});
});
});
describe("AggregationView", () => {
const nodeType: NodeType = {
name: "whatDoes",
pluralName: "whatDoth",
defaultWeight: 1,
prefix: NodeAddress.empty,
};
const edgeType: EdgeType = {
forwardName: "marsellus",
backwardName: "wallace",
prefix: EdgeAddress.fromParts(["look", "like"]),
};
function aggView(aggregation: FlatAggregation) {
const el = shallow(<AggregationView aggregation={aggregation} />);
const stuff = el.find("span").children();
const connectionDescription = stuff.at(0);
expect(connectionDescription.type()).toBe(Badge);
const summarySize = stuff.at(1);
expect(summarySize.type()).toBe("span");
const nodeName = stuff.at(2);
expect(nodeName.type()).toBe("span");
return {
connectionDescription: connectionDescription.props().children,
summarySize: summarySize.text(),
nodeName: nodeName.text(),
};
}
it("renders a synthetic connection", () => {
const synthetic = {
nodeType,
connectionType: {type: "SYNTHETIC_LOOP"},
summary: {size: 1, score: 1},
connections: [],
};
const {connectionDescription, summarySize, nodeName} = aggView(synthetic);
expect(connectionDescription).toBe("synthetic loop");
expect(summarySize).toBe(" 1 ");
expect(nodeName).toBe("whatDoes");
});
it("renders an inEdge connection", () => {
const inEdge = {
nodeType,
connectionType: {type: "IN_EDGE", edgeType},
summary: {size: 2, score: 1},
connections: [],
};
const {connectionDescription, summarySize, nodeName} = aggView(inEdge);
expect(connectionDescription).toBe("wallace");
expect(summarySize).toBe(" 2 ");
expect(nodeName).toBe("whatDoth");
});
it("renders an outEdge connection", () => {
const outEdge = {
nodeType,
connectionType: {type: "OUT_EDGE", edgeType},
summary: {size: 3, score: 1},
connections: [],
};
const {connectionDescription, summarySize, nodeName} = aggView(outEdge);
expect(connectionDescription).toBe("marsellus");
expect(summarySize).toBe(" 3 ");
expect(nodeName).toBe("whatDoth");
});
it("does not pluralize connections containing one element", () => {
const inEdge = {
nodeType,
connectionType: {type: "IN_EDGE", edgeType},
summary: {size: 1, score: 1},
connections: [],
};
const {nodeName} = aggView(inEdge);
expect(nodeName).toBe("whatDoes");
});
it("does pluralize connections containing multiple elements", () => {
const inEdge = {
nodeType,
connectionType: {type: "IN_EDGE", edgeType},
summary: {size: 2, score: 1},
connections: [],
};
const {nodeName} = aggView(inEdge);
expect(nodeName).toBe("whatDoth");
});
});
});

View File

@ -16,18 +16,18 @@ type ConnectionRowListProps = {|
+depth: number, +depth: number,
+node: NodeAddressT, +node: NodeAddressT,
+sharedProps: SharedProps, +sharedProps: SharedProps,
+connections: $ReadOnlyArray<ScoredConnection>,
|}; |};
export class ConnectionRowList extends React.PureComponent< export class ConnectionRowList extends React.PureComponent<
ConnectionRowListProps ConnectionRowListProps
> { > {
render() { render() {
const {depth, node, sharedProps} = this.props; const {depth, node, sharedProps, connections} = this.props;
const {pnd, maxEntriesPerList} = sharedProps; const {maxEntriesPerList} = sharedProps;
const {scoredConnections} = NullUtil.get(pnd.get(node));
return ( return (
<React.Fragment> <React.Fragment>
{scoredConnections {connections
.slice(0, maxEntriesPerList) .slice(0, maxEntriesPerList)
.map((sc) => ( .map((sc) => (
<ConnectionRow <ConnectionRow
@ -67,7 +67,7 @@ export class ConnectionRow extends React.PureComponent<ConnectionRowProps> {
); );
return ( return (
<TableRow <TableRow
indent={1} indent={2}
depth={depth} depth={depth}
description={connectionView} description={connectionView}
connectionProportion={connectionProportion} connectionProportion={connectionProportion}

View File

@ -30,11 +30,14 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
const depth = 2; const depth = 2;
const node = nodes.bar1; const node = nodes.bar1;
const sharedProps = {adapters, pnd, maxEntriesPerList}; const sharedProps = {adapters, pnd, maxEntriesPerList};
const connections = NullUtil.get(sharedProps.pnd.get(node))
.scoredConnections;
const component = ( const component = (
<ConnectionRowList <ConnectionRowList
depth={depth} depth={depth}
node={node} node={node}
sharedProps={sharedProps} sharedProps={sharedProps}
connections={connections}
/> />
); );
const element = shallow(component); const element = shallow(component);
@ -99,9 +102,9 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
const {row, depth} = await setup(); const {row, depth} = await setup();
expect(row.props().depth).toBe(depth); expect(row.props().depth).toBe(depth);
}); });
it("with indent=1", async () => { it("with indent=2", async () => {
const {row} = await setup(); const {row} = await setup();
expect(row.props().indent).toBe(1); expect(row.props().indent).toBe(2);
}); });
it("with showPadding=false", async () => { it("with showPadding=false", async () => {
const {row} = await setup(); const {row} = await setup();
@ -132,7 +135,7 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
const children = row.props().children; const children = row.props().children;
return shallow(children).instance(); return shallow(children).instance();
} }
it("which is a ConnectionRowList", async () => { it("which is a NodeRow", async () => {
const {row} = await setup(); const {row} = await setup();
expect(getChildren(row)).toBeInstanceOf(NodeRow); expect(getChildren(row)).toBeInstanceOf(NodeRow);
}); });

View File

@ -9,7 +9,7 @@ import {TableRow} from "./TableRow";
import {nodeDescription, type SharedProps} from "./shared"; import {nodeDescription, type SharedProps} from "./shared";
import {ConnectionRowList} from "./Connection"; import {AggregationRowList} from "./Aggregation";
type NodeRowListProps = {| type NodeRowListProps = {|
+nodes: $ReadOnlyArray<NodeAddressT>, +nodes: $ReadOnlyArray<NodeAddressT>,
@ -60,7 +60,7 @@ export class NodeRow extends React.PureComponent<NodeRowProps> {
connectionProportion={null} connectionProportion={null}
score={score} score={score}
> >
<ConnectionRowList <AggregationRowList
depth={depth} depth={depth}
node={node} node={node}
sharedProps={sharedProps} sharedProps={sharedProps}

View File

@ -5,7 +5,7 @@ import {shallow} from "enzyme";
import sortBy from "lodash.sortby"; import sortBy from "lodash.sortby";
import * as NullUtil from "../../../util/null"; import * as NullUtil from "../../../util/null";
import {TableRow} from "./TableRow"; import {TableRow} from "./TableRow";
import {ConnectionRowList} from "./Connection"; import {AggregationRowList} from "./Aggregation";
import {type NodeAddressT, NodeAddress} from "../../../core/graph"; import {type NodeAddressT, NodeAddress} from "../../../core/graph";
@ -128,14 +128,14 @@ describe("app/credExplorer/pagerankTable/Node", () => {
const description = shallow(row.props().description); const description = shallow(row.props().description);
expect(description.text()).toEqual(nodeDescription(node, adapters)); expect(description.text()).toEqual(nodeDescription(node, adapters));
}); });
describe("with a ConnectionRowList as children", () => { describe("with a AggregationRowList as children", () => {
function getChildren(row) { function getChildren(row) {
const children = row.props().children; const children = row.props().children;
return shallow(children).instance(); return shallow(children).instance();
} }
it("which is a ConnectionRowList", async () => { it("which is a AggregationRowList", async () => {
const {row} = await setup(); const {row} = await setup();
expect(getChildren(row)).toBeInstanceOf(ConnectionRowList); expect(getChildren(row)).toBeInstanceOf(AggregationRowList);
}); });
it("which has the same depth", async () => { it("which has the same depth", async () => {
const {row} = await setup({depth: 13}); const {row} = await setup({depth: 13});