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 type {Address} from "../../../core/address";
|
||||
import type {Node} from "../../../core/graph";
|
||||
import {AdapterSet} from "./adapterSet";
|
||||
import {Graph} from "../../../core/graph";
|
||||
|
||||
|
@ -10,23 +11,96 @@ type Props = {
|
|||
graph: ?Graph<any, any>,
|
||||
adapters: AdapterSet,
|
||||
};
|
||||
type State = {};
|
||||
type State = {
|
||||
typeFilter: ?{|
|
||||
+pluginName: string,
|
||||
+type: string,
|
||||
|},
|
||||
};
|
||||
|
||||
export class ContributionList extends React.Component<Props, State> {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
typeFilter: null,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Contributions</h2>
|
||||
{this.renderFilterSelect()}
|
||||
{this.renderTable()}
|
||||
</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() {
|
||||
if (this.props.graph == null) {
|
||||
return <div>(no graph)</div>;
|
||||
} else {
|
||||
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 (
|
||||
<table>
|
||||
<thead>
|
||||
|
@ -38,10 +112,13 @@ export class ContributionList extends React.Component<Props, State> {
|
|||
</thead>
|
||||
<tbody>
|
||||
{this.props.graph.getAllNodes().map((node) => {
|
||||
if (!shouldDisplay(node)) {
|
||||
return null;
|
||||
}
|
||||
const adapter = this.props.adapters.getAdapter(node);
|
||||
if (adapter == null) {
|
||||
return (
|
||||
<tr>
|
||||
<tr key={JSON.stringify(node.address)}>
|
||||
<td colspan={3}>
|
||||
<i>unknown</i> (plugin: {node.address.pluginName})
|
||||
</td>
|
||||
|
@ -49,7 +126,7 @@ export class ContributionList extends React.Component<Props, State> {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<tr>
|
||||
<tr key={JSON.stringify(node.address)}>
|
||||
<td>{adapter.extractTitle(graph, node)}</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