Allow filtering by type in the contribution list (#101)

Summary:
Filter options are “all contributions” or a specific plugin/type
combination. This includes a snapshot test for the static state. I’ll
add an interaction test in a subsequent commit.

Test Plan:
`yarn start`, fetch graph, play with the filtering options.

wchargin-branch: filter-contributions
This commit is contained in:
William Chargin 2018-03-20 18:50:25 -07:00 committed by GitHub
parent 39fd3fa354
commit 5dd5de306c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 373 additions and 3 deletions

View File

@ -3,6 +3,7 @@
import React from "react"; import React from "react";
import type {Address} from "../../../core/address"; import type {Address} from "../../../core/address";
import type {Node} from "../../../core/graph";
import {AdapterSet} from "./adapterSet"; import {AdapterSet} from "./adapterSet";
import {Graph} from "../../../core/graph"; import {Graph} from "../../../core/graph";
@ -10,23 +11,96 @@ type Props = {
graph: ?Graph<any, any>, graph: ?Graph<any, any>,
adapters: AdapterSet, adapters: AdapterSet,
}; };
type State = {}; type State = {
typeFilter: ?{|
+pluginName: string,
+type: string,
|},
};
export class ContributionList extends React.Component<Props, State> { export class ContributionList extends React.Component<Props, State> {
constructor() {
super();
this.state = {
typeFilter: null,
};
}
render() { render() {
return ( return (
<div> <div>
<h2>Contributions</h2> <h2>Contributions</h2>
{this.renderFilterSelect()}
{this.renderTable()} {this.renderTable()}
</div> </div>
); );
} }
renderFilterSelect() {
if (this.props.graph == null) {
return null;
}
const graph: Graph<any, any> = this.props.graph;
const typesByPlugin: {[pluginName: string]: Set<string>} = {};
graph.getAllNodes().forEach((node) => {
const adapter = this.props.adapters.getAdapter(node);
if (adapter == null) {
return;
}
if (!typesByPlugin[adapter.pluginName]) {
typesByPlugin[adapter.pluginName] = new Set();
}
typesByPlugin[adapter.pluginName].add(adapter.extractType(graph, node));
});
function optionGroup(pluginName: string) {
const header = (
<option key={pluginName} disabled style={{fontWeight: "bold"}}>
{pluginName}
</option>
);
const entries = Array.from(typesByPlugin[pluginName])
.sort()
.map((type) => (
<option key={type} value={JSON.stringify({pluginName, type})}>
{"\u2003" + type}
</option>
));
return [header, ...entries];
}
return (
<label>
Filter by contribution type:{" "}
<select
value={JSON.stringify(this.state.typeFilter)}
onChange={(e) => {
this.setState({typeFilter: JSON.parse(e.target.value)});
}}
>
<option value={JSON.stringify(null)}>Show all</option>
{Object.keys(typesByPlugin)
.sort()
.map(optionGroup)}
</select>
</label>
);
}
renderTable() { renderTable() {
if (this.props.graph == null) { if (this.props.graph == null) {
return <div>(no graph)</div>; return <div>(no graph)</div>;
} else { } else {
const graph: Graph<any, any> = this.props.graph; const graph: Graph<any, any> = this.props.graph;
const {typeFilter} = this.state;
const shouldDisplay: (node: Node<any>) => boolean = typeFilter
? (node) => {
const adapter = this.props.adapters.getAdapter(node);
return (
!!adapter &&
adapter.pluginName === typeFilter.pluginName &&
adapter.extractType(graph, node) === typeFilter.type
);
}
: (node) => true;
return ( return (
<table> <table>
<thead> <thead>
@ -38,10 +112,13 @@ export class ContributionList extends React.Component<Props, State> {
</thead> </thead>
<tbody> <tbody>
{this.props.graph.getAllNodes().map((node) => { {this.props.graph.getAllNodes().map((node) => {
if (!shouldDisplay(node)) {
return null;
}
const adapter = this.props.adapters.getAdapter(node); const adapter = this.props.adapters.getAdapter(node);
if (adapter == null) { if (adapter == null) {
return ( return (
<tr> <tr key={JSON.stringify(node.address)}>
<td colspan={3}> <td colspan={3}>
<i>unknown</i> (plugin: {node.address.pluginName}) <i>unknown</i> (plugin: {node.address.pluginName})
</td> </td>
@ -49,7 +126,7 @@ export class ContributionList extends React.Component<Props, State> {
); );
} else { } else {
return ( return (
<tr> <tr key={JSON.stringify(node.address)}>
<td>{adapter.extractTitle(graph, node)}</td> <td>{adapter.extractTitle(graph, node)}</td>
<td>[TODO]</td> <td>[TODO]</td>
<td>[TODO]</td> <td>[TODO]</td>

View File

@ -0,0 +1,162 @@
// @flow
import React from "react";
import ReactDOM from "react-dom";
import reactTestRenderer from "react-test-renderer";
import type {Address} from "../../../core/address";
import type {Node} from "../../../core/graph";
import type {PluginAdapter} from "./pluginAdapter";
import {AdapterSet} from "./adapterSet";
import {ContributionList} from "./ContributionList";
import {Graph} from "../../../core/graph";
require("./testUtil").configureAphrodite();
function createTestData(): * {
type PayloadA = number;
type PayloadB = boolean;
type PayloadC = string;
type NodePayload = PayloadA | PayloadB | PayloadC;
type EdgePayload = null;
const PLUGIN_A = "sourcecred/example-plugin-a";
const PLUGIN_B = "sourcecred/example-plugin-b";
const PLUGIN_C = "sourcecred/example-plugin-c";
function makeAddress(
pluginName: typeof PLUGIN_A | typeof PLUGIN_B | typeof PLUGIN_C,
id: string
): Address {
return {
repositoryName: "sourcecred/tests",
pluginName,
id,
};
}
const nodeA1 = () => ({
address: makeAddress(PLUGIN_A, "one"),
payload: (111: PayloadA),
});
const nodeA2 = () => ({
address: makeAddress(PLUGIN_A, "two"),
payload: (234: PayloadA),
});
const nodeA3 = () => ({
address: makeAddress(PLUGIN_A, "three"),
payload: (616: PayloadA),
});
const nodeB4 = () => ({
address: makeAddress(PLUGIN_B, "four"),
payload: (true: PayloadB),
});
const nodeC5 = () => ({
address: makeAddress(PLUGIN_C, "five"),
payload: ("I have no adapter :-(": PayloadC),
});
const edgeA1A2 = () => ({
address: makeAddress(PLUGIN_A, "one-to-two"),
payload: null,
src: nodeA1().address,
dst: nodeA2().address,
});
const edgeB4A3 = () => ({
address: makeAddress(PLUGIN_C, "four-to-three"),
payload: null,
src: nodeB4().address,
dst: nodeA3().address,
});
const graph: () => Graph<NodePayload, EdgePayload> = () =>
new Graph()
.addNode(nodeA1())
.addNode(nodeA2())
.addNode(nodeA3())
.addNode(nodeB4())
.addNode(nodeC5())
.addEdge(edgeA1A2())
.addEdge(edgeB4A3());
const adapterA: () => PluginAdapter<PayloadA> = () => ({
pluginName: PLUGIN_A,
renderer: class RendererA extends React.Component<{
graph: Graph<any, any>,
node: Node<PayloadA>,
}> {
render() {
const {graph, node} = this.props;
const neighborCount = graph.getOutEdges(node.address).length;
return (
<span>
<tt>{node.address.id}</tt> has neighbor count{" "}
<strong>{neighborCount}</strong>
</span>
);
}
},
extractType(graph: Graph<NodePayload, EdgePayload>, node: Node<PayloadA>) {
return node.payload < 500 ? "small" : "big";
},
extractTitle(graph: Graph<NodePayload, EdgePayload>, node: Node<PayloadA>) {
return `the number ${String(node.payload)}`;
},
});
const adapterB: () => PluginAdapter<PayloadB> = () => ({
pluginName: PLUGIN_B,
renderer: class RendererB extends React.Component<{
graph: Graph<any, any>,
node: Node<PayloadB>,
}> {
render() {
const {node} = this.props;
return (
<span>
Node <em>{node.address.id}</em>: <strong>{node.payload}</strong>
</span>
);
}
},
extractType(graph: Graph<NodePayload, EdgePayload>, node: Node<PayloadB>) {
return node.payload ? "very true" : "not so";
},
extractTitle(graph: Graph<NodePayload, EdgePayload>, node: Node<PayloadB>) {
return String(node.payload).toUpperCase() + "!";
},
});
const adapters: () => AdapterSet = () => {
const result = new AdapterSet();
result.addAdapter(adapterA());
result.addAdapter(adapterB());
return result;
};
return {
PLUGIN_A,
PLUGIN_B,
PLUGIN_C,
nodeA1,
nodeA2,
nodeA3,
nodeB4,
nodeC5,
edgeA1A2,
edgeB4A3,
graph,
adapterA,
adapterB,
adapters,
};
}
describe("ContributionList", () => {
it("renders some test data in the default state", () => {
const data = createTestData();
const result = reactTestRenderer.create(
<ContributionList graph={data.graph()} adapters={data.adapters()} />
);
expect(result).toMatchSnapshot();
});
});

View File

@ -0,0 +1,131 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ContributionList renders some test data in the default state 1`] = `
<div>
<h2>
Contributions
</h2>
<label>
Filter by contribution type:
<select
onChange={[Function]}
value="null"
>
<option
value="null"
>
Show all
</option>
<option
disabled={true}
style={
Object {
"fontWeight": "bold",
}
}
>
sourcecred/example-plugin-a
</option>
<option
value="{\\"pluginName\\":\\"sourcecred/example-plugin-a\\",\\"type\\":\\"big\\"}"
>
big
</option>
<option
value="{\\"pluginName\\":\\"sourcecred/example-plugin-a\\",\\"type\\":\\"small\\"}"
>
small
</option>
<option
disabled={true}
style={
Object {
"fontWeight": "bold",
}
}
>
sourcecred/example-plugin-b
</option>
<option
value="{\\"pluginName\\":\\"sourcecred/example-plugin-b\\",\\"type\\":\\"very true\\"}"
>
very true
</option>
</select>
</label>
<table>
<thead>
<tr>
<th>
Title
</th>
<th>
Artifact
</th>
<th>
Weight
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
the number 111
</td>
<td>
[TODO]
</td>
<td>
[TODO]
</td>
</tr>
<tr>
<td>
the number 234
</td>
<td>
[TODO]
</td>
<td>
[TODO]
</td>
</tr>
<tr>
<td>
the number 616
</td>
<td>
[TODO]
</td>
<td>
[TODO]
</td>
</tr>
<tr>
<td>
TRUE!
</td>
<td>
[TODO]
</td>
<td>
[TODO]
</td>
</tr>
<tr>
<td
colspan={3}
>
<i>
unknown
</i>
(plugin:
sourcecred/example-plugin-c
)
</td>
</tr>
</tbody>
</table>
</div>
`;