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:
parent
39fd3fa354
commit
5dd5de306c
|
@ -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>
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
`;
|
Loading…
Reference in New Issue