blob: ae544c8dc9c34be0985dbf14950b9485907543df [file] [log] [blame]
<!--
This in an HTML Import-able file that contains the definition
of the following elements:
<commits-table-d3-sk>
This uses d3 to draw the commit table. Unlike previous versions which used a table and basically
drew the table row by row, this implementation draws it column by column. Because filtering
happens on a builder (column) basis, drawing it column by column results in simpler code.
D3 is much much faster than Polymer for this type of data visualization, although this element
still offers a Polymer interface. If any of the inputs change, the table will be redrawn.
To use this file import it:
<link href="/res/imp/commits-table-d3-sk" rel="import" />
Usage:
<commits-table-d3-sk></commits-table-d3-sk>
Properties:
// inputs
build_details: Object, a map of commit hash to an object that has the build results by builder.
builders: Object, a map of the builder names to an object that has, among other things, category,
subcategory, comments and master.
builds: Object, a map of the builder names to an object that maps build numbers to build results.
categories: Object, a map of the builder categories to an object that has the subcategories and
the colspan (total number of included builders).
category_list: Array<String>, an array of the builder category names.
commits: Array<Object>, the commit objects, in chronological detail.
commits_map: Object, a map of commit hash to commit objects.
highlighted_commit_hashes: Array<String>, the commit hashes which should be highlighted.
logged_in: Boolean, if the links should be for internal or external buildbot pages.
num_builders: Number, the number of builders with data, after filtering.
relanded_map: Object, a map of an issue number that was relanded to the commit that relands it.
repo: String, the current repo. Used to direct comments to the right place.
repo_base: The base URL for commits. Commit hashes will be appended to this.
reverted_map: Object, a map of an issue number that was reverted to the commit that reverts it.
Methods:
None.
Events:
None.
-->
<script src="/res/imp/bower_components/d3/d3.min.js"></script>
<link rel="import" href="/res/imp/bower_components/iron-flex-layout/iron-flex-layout-classes.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/iron-icons.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/communication-icons.html">
<link rel="import" href="/res/imp/bower_components/iron-icons/image-icons.html">
<link rel="import" href="/res/imp/bower_components/paper-dialog/paper-dialog.html">
<link rel="import" href="build-popup-sk.html">
<link rel="import" href="builder-popup-sk.html">
<link rel="import" href="commit-popup-sk.html">
<link rel="stylesheet" href="commits-table-d3.css">
<dom-module id="commits-table-d3-sk">
<template>
<style include="iron-flex">
#container {
overflow-x: hidden;
}
#commits {
min-height: 780px;
min-width: 50px;
}
#builds {
border: 1px solid blue;
min-height: 400px;
}
#legend {
border-right: 1px solid black;
font-size: 10px;
height: 68px;
}
#table {
overflow-x: auto;
overflow-y: hidden;
/* 80 pixels of header and 35 * 20px of commits.
It is important to preallocate this size, otherwise the flexbox layout
behaves strangely (i.e. wrong) when the data is first added. */
min-height: 780px;
}
</style>
<div id="container" class="horizontal layout">
<div id="commits">
<div id="legend">
<iron-icon class="tiny" icon="communication:chat"></iron-icon> comment<br/>
<iron-icon class="tiny" icon="image:texture"></iron-icon> flaky<br/>
<iron-icon class="tiny" icon="icons:block"></iron-icon> ignore failure<br/>
<iron-icon class="tiny revert" icon="icons:undo"></iron-icon> reverted<br/>
<iron-icon class="tiny reland" icon="icons:redo"></iron-icon> relanded<br/>
</div>
</div>
<div id="table" class="flex horizontal layout"></div>
</div>
<div id="infoDialog"></div>
</template>
<script>
(function(){
var BUILD_HEIGHT = 20;
Polymer({
is: 'commits-table-d3-sk',
properties: {
// inputs from data source to render.
builders: {
type: Object,
},
build_details: {
type: Object,
},
builds: {
type: Object,
},
categories: {
type: Object,
},
category_list: {
type: Array,
},
commit_label: {
type: String,
},
commits: {
type: Array,
},
commits_map: {
type: Object,
},
highlighted_commit_hashes: {
type: Array,
},
logged_in: {
type: Boolean,
},
relanded_map: {
type: Object,
},
repo: {
type: String,
},
repo_base: {
type: String,
},
reverted_map: {
type: Object,
},
time_points: {
type: Object,
},
// outputs
drawing: {
type: Boolean,
notify: true,
value: false,
}
},
observers: [
"redraw(builders, build_details, categories, category_list, commit_label, commits, commits_map, highlighted_commit_hashes, relanded_map, repo, reverted_map, time_points)",
],
ready: function() {
// We redraw, so as to try to better fit the screen size.
d3.select(window).on('resize', this.redraw.bind(this));
},
redraw:function() {
// This gets called any time one of the values changes. Since the values are
// updated simultaneously, we don't want to try to draw the table many times at the
// same time, so we debounce it. No timeout on the debounce because all the this.set
// calls will happen before a paint call, so this just basically collates all the requests.
this.debounce("redraw-commits-table", function(){
this._redraw();
}.bind(this));
},
_redraw:function() {
this.set("drawing", true);
console.time("d3 rendering");
this._drawCommitMessages(this.commits, this.highlighted_commit_hashes, this.relanded_map, this.reverted_map, this.time_points);
this._drawCategories(this.category_list, this.categories);
this._drawSubCategories(this.categories);
this._drawBuilderColumns(this.categories, this.builders, this.commits.length);
this._drawBuilds(this.commits, this.build_details);
console.timeEnd("d3 rendering");
this.set("drawing", false);
},
// Draw all of the commit divs that are on the left.
_drawCommitMessages: function(commits, highlighted_commit_hashes, relanded_map, reverted_map, time_points){
var openCommitDialog = this._openCommitDialog.bind(this);
var commitLabel = this.commit_label;
// It's easiest to delete all .commits and redraw them. Normally, we would only update
// the ones we want, but since we have a composed element and there aren't very many
// commits, starting from scratch is simplest.
d3.select(this.$.commits).selectAll(".commit").remove();
var data = d3.select(this.$.commits).selectAll(".commit")
.data(commits, function(commit, i){
commit.index = i;
return commit.hash;
});
// Create a commit div for each commit with id "patch_storage + codereview_number".
// Eg: "rietveld12345" for Rietveld and "gerrit12345" for Gerrit.
// For posterity: Element selectors choke if an id starts with a number, thus the
// patch_storage prefix helps with that.
var newCommits = data
.enter()
.append("div")
.classed("commit",true)
.attr("id", function(commit){
return commit.patchStorage + commit.issue;
})
.on("tap", function(commit) {
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openCommitDialog(commit);
});
// Sort the commits to be in the order we are presented with them.
data.order();
// Set the mouseover to be the opposite of what the label says.
newCommits
.attr("title", function(commit) {
if (commitLabel == "author") {
return commit.shortSubject;
}
return commit.author;
});
// handle highlighted and alternating grey/white logic.
newCommits
.insert("div")
.classed("back", true)
.style("background-color", function(commit){
if (highlighted_commit_hashes.indexOf(commit.hash) !== -1){
return "#FFD740";
} else if (commit.index % 2 === 1) {
return "#FFF";
}
});
// Add a spacer to every commit where the time bubble could be. This allows all
// the bubbles and non-bubble commits to line up vertically.
var spacers = newCommits
.insert("div")
.classed("time-spacer",true);
spacers.each(function(commit) {
var point = time_points[commit.hash];
if (point) {
d3.select(this)
.append("span")
.classed("time-underline", true);
d3.select(this)
.insert("span")
.classed("time", true)
.attr("title", point.label)
.text(point.label);
}
});
// Insert a span with the commit label.
newCommits
.insert("span")
.classed("author", function(){
return commitLabel == "author";
})
.classed("subject", function(){
return commitLabel == "subject";
})
.text(function(commit) {
if (commitLabel == "author") {
return commit.shortAuthor || "[no author]";
}
return commit.shortSubject || "[no subject]";
});
newCommits
.insert("div")
.classed("flex", true);
// On the other side, conditionally draw iron icons for comments, relands, reverts.
this._addIronIcon(newCommits, "communication:chat", "tiny", function(commit){
return commit.comments && commit.comments.length > 0;
});
this._addIronIcon(newCommits, "icons:block", "tiny", function(commit){
return commit.ignoreFailure;
});
var reverts = this._addIronIcon(newCommits, "icons:undo", "tiny revert", function(commit){
var reverterCommit = reverted_map[commit.issue];
return (reverterCommit !== undefined && reverterCommit.timestamp > commit.timestamp);
});
reverts.on("mouseover", function(commit){
var reverterIssue = reverted_map[commit.issue].issue;
d3.select("#"+commit.patchStorage+reverterIssue).classed("revert", true);
})
reverts.on("mouseleave", function(commit){
d3.selectAll(".commit.revert").classed("revert", false);
})
var relands = this._addIronIcon(newCommits, "icons:redo","tiny reland", function(commit){
var relanderCommit = relanded_map[commit.issue];
return (relanderCommit !== undefined && relanderCommit.timestamp > commit.timestamp);
});
relands.on("mouseover", function(commit){
var relanderIssue = relanded_map[commit.issue].issue;
d3.select("#"+commit.patchStorage+relanderIssue).classed("reland", true);
})
relands.on("mouseleave", function(commit){
d3.selectAll(".commit.reland").classed("reland", false);
})
},
// Adds iron icons to everything in the selection if shouldAddIcon returns truthy. It
// returns all icons that were added.
_addIronIcon: function(selection, icon, classes, shouldAddIcon) {
return selection
.insert("span")
.html(function(commit){
// The Polymer templates will pick this up and make it a real iron-icon element.
// However, if you try to do .insert("iron-icon")... that does not work.
if (shouldAddIcon === undefined || shouldAddIcon(commit)) {
return "<iron-icon class=\""+classes+"\" icon=\"" + icon + "\"></iron-icon>";
}
});
},
// Draw all of the divs for the categories, including the header.
_drawCategories: function(category_list, categories){
var data = d3.select(this.$.table).selectAll(".category")
.data(category_list, function(category){
return category;
});
data.exit().remove();
var newCategories = data
.enter()
.append("div")
.classed("category",true);
data.order();
data
.style("flex-grow", function(category){
// This keeps the rows about as evenly sized as possible.
// colspan is the total number of builders in this category.
return categories[category].colspan;
});
newCategories
.insert("div")
.classed("category-title", true)
.text(function(category){
return category;
})
// .subcategories will hold all of the .subcategory elements.
newCategories
.insert("div")
.classed("subcategories", true);
},
// Draw all of the divs for the subcategories, including the header
_drawSubCategories: function(categories) {
d3.select(this.$.table).selectAll(".subcategories").each(function(category){
if (!categories[category]) {
return;
}
var list = categories[category].subcategoryList.map(function(subcat){
return {
"category": category,
"subcategory": subcat,
};
});
var data = d3.select(this)
.selectAll(".subcategory")
.data(list, function(d) {
return d.category + d.subcategory;
});
data.exit().remove();
var subcategories = data
.enter()
.insert("div")
.classed("subcategory", true);
data.order();
data
.style("flex-grow", function(d){
// This keeps the rows about as evenly sized as possible.
return categories[d.category].subcategories[d.subcategory].builders.length;
});
subcategories
.insert("div")
.classed("subcategory-title", true)
.text(function(d){
return d.subcategory;
});
// .builders will hold all of the .builder elements.
subcategories
.insert("div")
.classed("builders", true);
});
},
// draw all of the builder columns, including the header box with the flaky, ignore_failure,
// and comment icons
_drawBuilderColumns: function(categories, builders, num_commits) {
var addIronIcon = this._addIronIcon.bind(this);
var openBuilderDialog = this._openBuilderDialog.bind(this);
d3.select(this.$.table).selectAll(".builders").each(function(d){
if (!categories[d.category].subcategories[d.subcategory]) {
return;
}
var list = categories[d.category].subcategories[d.subcategory].builders.map(function(builder){
return {
"category": d.category,
"subcategory": d.subcategory,
"builder": builder,
};
});
var data = d3.select(this)
.selectAll(".builder")
.data(list, function(d) {
return d.category + d.subcategory + d.builder;
});
data.exit().remove();
var newBuilders = data
.enter()
.insert("div")
.classed("builder", true);
data.order();
var titles = newBuilders
.insert("div")
.classed("builder-title", true)
.attr("title", function(d){
return d.builder;
})
.on("tap", function(d){
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openBuilderDialog(d.builder);
});
addIronIcon(titles, "icons:block", "tiny", function(d){
return builders[d.builder].ignoreFailure;
});
addIronIcon(titles, "image:texture", "tiny", function(d){
return builders[d.builder].flaky;
});
addIronIcon(titles, "communication:chat", "tiny", function(d){
return builders[d.builder].comments && builders[d.builder].comments.length > 0;
});
newBuilders
.insert("div")
.classed("builder-spacer", true);
// Set the height so that flexing doesn't throw off the multi column alignment.
newBuilders
.insert("div")
.classed("builds", true)
.style("max-height", function(){
return (BUILD_HEIGHT * num_commits) + "px";
});
// .style("min-height", function(){
// return (BUILD_HEIGHT * num_commits) + "px";
// });
});
},
// Draws the build divs. We forego flexbox helping us align these. First, we create a build
// div (which is a fixed height) in side of it create a div with the various build classes to
// set the heights/widths/margins etc. This is easier to line up with the adjacent columns
// and allows for more consistent zooming behavior.
_drawBuilds: function(commits, build_details) {
var openBuildDialog = this._openBuildDialog.bind(this);
var addIronIcon = this._addIronIcon.bind(this);
d3.select(this.$.table).selectAll(".builds").each(function(d){
var list = commits.map(function(commit) {
return {
"category": d.category,
"subcategory": d.subcategory,
"builder": d.builder,
"commit": commit,
"displayClass": commit.displayClass[d.builder] || [],
};
});
var data = d3.select(this)
.selectAll(".build")
.data(list, function(d) {
return d.category + d.subcategory + d.builder + d.commit.hash;
});
data.exit().remove();
data
.enter()
.insert("div")
.classed("build",true)
.insert("div")
.on("tap", function(d){
// Stop the propogation so we don't immediately hide the popup we show.
d3.event.stopPropagation();
openBuildDialog(d);
});
data.order();
// Style the inside div.
data.each(function(d){
var build = this.children[0];
// Clear any icons already drawn in the child.
build.innerHTML = "";
var details = build_details[d.commit.hash] && build_details[d.commit.hash][d.builder];
build.className = d.displayClass.join(" ");
if (details) {
build.style["background-color"] = build_details[d.commit.hash][d.builder].color;
build.title = d.builder + ", #"+details.number;
addIronIcon(d3.select(build), "communication:chat", "tiny", function(){
return details.comments.length > 0 &&
(build.className.indexOf(CLASS_BUILD_SINGLE) >= 0
|| build.className.indexOf(CLASS_BUILD_TOP) >= 0);
});
} else {
build.style["background-color"] = "";
build.title = "";
}
});
});
},
_openBuildDialog: function(build) {
if (this._infoPopupOpen()) {
return;
}
var details = this.build_details[build.commit.hash][build.builder];
if (details) {
var buildInfo = document.createElement("build-popup-sk");
buildInfo.build = details;
buildInfo.buildbot_url_prefix = status_utils.getBuildbotUrlPrefix(details, this.logged_in);
buildInfo.repo = this.repo;
buildInfo.repo_base = this.repo_base;
buildInfo.commit_details = this.commits_map;
buildInfo.parent = this;
this._openDialog(buildInfo);
}
},
_openBuilderDialog: function(builder) {
if (this._infoPopupOpen()) {
return;
}
if (builder) {
var builderInfo = document.createElement("builder-popup-sk");
builderInfo.builder = this.builders[builder];
for (var buildNum in this.builds[builder]) {
builderInfo.buildbot_url_prefix = status_utils.getBuildbotUrlPrefix(this.builds[builder][buildNum], this.logged_in);
break;
}
builderInfo.repo = this.repo;
this._openDialog(builderInfo);
}
},
_openCommitDialog: function(commit) {
if (this._infoPopupOpen()) {
return;
}
var commitInfo = document.createElement("commit-popup-sk");
commitInfo.commit = commit;
commitInfo.repo = this.repo;
commitInfo.repo_base = this.repo_base;
this._openDialog(commitInfo);
},
// Is the info popup open?
_infoPopupOpen: function() {
return this.$.infoDialog.opened;
},
// Set the dialog content and open the dialog.
_openDialog: function(child) {
this.$.infoDialog.innerHTML = '';
this.$.infoDialog.appendChild(child);
child.show();
console.log("show");
},
});
})()
</script>
</dom-module>