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:
parent
8df0056f08
commit
094582be32
|
@ -2,4 +2,5 @@
|
|||
|
||||
## [Unreleased]
|
||||
- Start tracking changes in `CHANGELOG.md`
|
||||
- Aggregate over connection types in the cred explorer (#502)
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -16,18 +16,18 @@ type ConnectionRowListProps = {|
|
|||
+depth: number,
|
||||
+node: NodeAddressT,
|
||||
+sharedProps: SharedProps,
|
||||
+connections: $ReadOnlyArray<ScoredConnection>,
|
||||
|};
|
||||
|
||||
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));
|
||||
const {depth, node, sharedProps, connections} = this.props;
|
||||
const {maxEntriesPerList} = sharedProps;
|
||||
return (
|
||||
<React.Fragment>
|
||||
{scoredConnections
|
||||
{connections
|
||||
.slice(0, maxEntriesPerList)
|
||||
.map((sc) => (
|
||||
<ConnectionRow
|
||||
|
@ -67,7 +67,7 @@ export class ConnectionRow extends React.PureComponent<ConnectionRowProps> {
|
|||
);
|
||||
return (
|
||||
<TableRow
|
||||
indent={1}
|
||||
indent={2}
|
||||
depth={depth}
|
||||
description={connectionView}
|
||||
connectionProportion={connectionProportion}
|
||||
|
|
|
@ -30,11 +30,14 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
|
|||
const depth = 2;
|
||||
const node = nodes.bar1;
|
||||
const sharedProps = {adapters, pnd, maxEntriesPerList};
|
||||
const connections = NullUtil.get(sharedProps.pnd.get(node))
|
||||
.scoredConnections;
|
||||
const component = (
|
||||
<ConnectionRowList
|
||||
depth={depth}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
connections={connections}
|
||||
/>
|
||||
);
|
||||
const element = shallow(component);
|
||||
|
@ -99,9 +102,9 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
|
|||
const {row, depth} = await setup();
|
||||
expect(row.props().depth).toBe(depth);
|
||||
});
|
||||
it("with indent=1", async () => {
|
||||
it("with indent=2", async () => {
|
||||
const {row} = await setup();
|
||||
expect(row.props().indent).toBe(1);
|
||||
expect(row.props().indent).toBe(2);
|
||||
});
|
||||
it("with showPadding=false", async () => {
|
||||
const {row} = await setup();
|
||||
|
@ -132,7 +135,7 @@ describe("app/credExplorer/pagerankTable/Connection", () => {
|
|||
const children = row.props().children;
|
||||
return shallow(children).instance();
|
||||
}
|
||||
it("which is a ConnectionRowList", async () => {
|
||||
it("which is a NodeRow", async () => {
|
||||
const {row} = await setup();
|
||||
expect(getChildren(row)).toBeInstanceOf(NodeRow);
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@ import {TableRow} from "./TableRow";
|
|||
|
||||
import {nodeDescription, type SharedProps} from "./shared";
|
||||
|
||||
import {ConnectionRowList} from "./Connection";
|
||||
import {AggregationRowList} from "./Aggregation";
|
||||
|
||||
type NodeRowListProps = {|
|
||||
+nodes: $ReadOnlyArray<NodeAddressT>,
|
||||
|
@ -60,7 +60,7 @@ export class NodeRow extends React.PureComponent<NodeRowProps> {
|
|||
connectionProportion={null}
|
||||
score={score}
|
||||
>
|
||||
<ConnectionRowList
|
||||
<AggregationRowList
|
||||
depth={depth}
|
||||
node={node}
|
||||
sharedProps={sharedProps}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {shallow} from "enzyme";
|
|||
import sortBy from "lodash.sortby";
|
||||
import * as NullUtil from "../../../util/null";
|
||||
import {TableRow} from "./TableRow";
|
||||
import {ConnectionRowList} from "./Connection";
|
||||
import {AggregationRowList} from "./Aggregation";
|
||||
|
||||
import {type NodeAddressT, NodeAddress} from "../../../core/graph";
|
||||
|
||||
|
@ -128,14 +128,14 @@ describe("app/credExplorer/pagerankTable/Node", () => {
|
|||
const description = shallow(row.props().description);
|
||||
expect(description.text()).toEqual(nodeDescription(node, adapters));
|
||||
});
|
||||
describe("with a ConnectionRowList as children", () => {
|
||||
describe("with a AggregationRowList as children", () => {
|
||||
function getChildren(row) {
|
||||
const children = row.props().children;
|
||||
return shallow(children).instance();
|
||||
}
|
||||
it("which is a ConnectionRowList", async () => {
|
||||
it("which is a AggregationRowList", async () => {
|
||||
const {row} = await setup();
|
||||
expect(getChildren(row)).toBeInstanceOf(ConnectionRowList);
|
||||
expect(getChildren(row)).toBeInstanceOf(AggregationRowList);
|
||||
});
|
||||
it("which has the same depth", async () => {
|
||||
const {row} = await setup({depth: 13});
|
||||
|
|
Loading…
Reference in New Issue