Bug 1242905 - Revision list react component (#2029)

Begins migration of job rendering to react by replacing cloned revision
lists with a new react component. Related changes:

- Prepends the ignore-job-on-click attribute with data- so that react
  will render it
- Makes the linkifyBugs filter wrap its html attributes in quotes
  consistently
- Adds explict whitespace via CSS in a few places that previously
  depended on whitespace in markup
This commit is contained in:
Casey Williams 2016-12-22 11:35:00 -08:00 коммит произвёл camd
Родитель 5a2745c2e4
Коммит 676e1f4738
16 изменённых файлов: 334 добавлений и 107 удалений

Просмотреть файл

@ -13,6 +13,9 @@ module.exports = function (config) {
files: [
'ui/vendor/angular/angular.js',
'ui/vendor/angular/angular-*.js',
'tests/ui/vendor/react-with-addons.min.js',
'ui/vendor/react/react-dom.min.js',
'ui/vendor/ngReact/ngReact.min.js',
'ui/vendor/ui-bootstrap-*.js',
'ui/vendor/jquery-*.js',
'ui/vendor/jquery.ui.effect.js',
@ -30,6 +33,7 @@ module.exports = function (config) {
'ui/js/directives/treeherder/**/*.js',
'ui/js/models/**/*.js',
'ui/js/services/**/*.js',
'ui/js/reactrevisions.js',
'ui/plugins/**/*.js',
'tests/ui/vendor/jasmine-jquery.js',
'tests/ui/unit/**/*.js',

Просмотреть файл

@ -35,13 +35,13 @@ describe('linkifyBugs filter', function() {
it('linkifies a Bug', function() {
var linkifyBugs = $filter('linkifyBugs');
expect(linkifyBugs('Bug 123456'))
.toEqual('Bug <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=123456" data-bugid=123456 title=bugzilla.mozilla.org>123456</a>');
.toEqual('Bug <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=123456" data-bugid="123456" title="bugzilla.mozilla.org">123456</a>');
});
it('linkifies a PR', function() {
var linkifyBugs = $filter('linkifyBugs');
expect(linkifyBugs('PR#123456'))
.toEqual('PR#<a href="https://github.com/mozilla-b2g/gaia/pull/123456" data-prid=123456 title=github.com>123456</a>');
.toEqual('PR#<a href="https://github.com/mozilla-b2g/gaia/pull/123456" data-prid="123456" title="github.com">123456</a>');
});
});

Просмотреть файл

@ -0,0 +1,147 @@
'use strict';
describe('Revision list react component', () => {
var $filter;
var compile, mockData;
beforeEach(module('treeherder'));
beforeEach(module('react'));
beforeEach(inject((_$filter_) => {
$filter = _$filter_;
}));
beforeEach(inject(() => {
var resultset = {
"id": 151371,
"revision_hash": "0056da58e1efd70711c8f98336eaf866f1aa8936",
"revision": "5a110ad242ead60e71d2186bae78b1fb766ad5ff",
"revisions_uri": "/api/project/mozilla-inbound/resultset/151371/revisions/",
"revision_count": 3,
"author": "ryanvm@gmail.com",
"push_timestamp": 1481326280,
"repository_id": 2,
"revisions": [{
"result_set_id": 151371,
"repository_id": 2,
"revision": "5a110ad242ead60e71d2186bae78b1fb766ad5ff",
"author":"André Bargull <andre.bargull@gmail.com>",
"comments": "Bug 1319926 - Part 2: Collect telemetry about deprecated String generics methods. r=jandem"
},{
"result_set_id": 151371,
"repository_id": 2,
"revision": "07d6bf74b7a2552da91b5e2fce0fa0bc3b457394",
"author": "André Bargull <andre.bargull@gmail.com>",
"comments":"Bug 1319926 - Part 1: Warn when deprecated String generics methods are used. r=jandem"
},{
"result_set_id": 151371,
"repository_id": 2,
"revision": "e83eaf2380c65400dc03c6f3615d4b2cef669af3",
"author": "Frédéric Wang <fred.wang@free.fr>",
"comments": "Bug 1322743 - Add STIX Two Math to the list of math fonts. r=karlt"
}]
};
var repo = {
"id": 2,
"repository_group": {
"name": "development",
"description": ""
},
"name": "mozilla-inbound",
"dvcs_type": "hg",
"url": "https://hg.mozilla.org/integration/mozilla-inbound",
"branch": null,
"codebase": "gecko",
"description": "",
"active_status": "active",
"performance_alerts_enabled": true,
"pushlogURL": "https://hg.mozilla.org/integration/mozilla-inbound/pushloghtml"
};
// Mock these simple functions so we don't have to call ThRepositoryModel.load() first to use it
repo.getRevisionHref = () => `${repo.url}/rev/${resultset.revision}`;
repo.getPushLogHref = (revision) => `${repo.pushlogURL}?changeset=${revision}`;
mockData = {
resultset,
repo
};
}));
beforeEach(inject(($rootScope, $timeout, $compile) => {
compile = function(el, scope) {
var $scope = $rootScope.$new();
$scope = _.extend($scope, scope);
var compiledEl = $compile(el)($scope);
$timeout.flush();
$scope.$digest();
return compiledEl;
};
}));
it('renders the correct number of revisions in a list', () => {
var component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
var revisionItems = component[0].querySelectorAll('li');
expect(revisionItems.length).toEqual(mockData['resultset']['revision_count']);
});
it('renders the linked revision hashes', () => {
var component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
var links = component[0].querySelectorAll('.revision-holder a');
expect(links.length).toEqual(mockData['resultset']['revision_count']);
Array.prototype.forEach.call(links, (link, i) => {
expect(link.href).toEqual(mockData.repo.getRevisionHref());
expect(link.title).toEqual(`Open revision ${mockData.resultset.revisions[i].revision} on ${mockData.repo.url}`);
});
});
it('renders the contributors\' initials', () => {
var component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
var initials = component[0].querySelectorAll('.label.label-initials');
expect(initials.length).toEqual(mockData.resultset.revision_count);
Array.prototype.forEach.call(initials, (initial, i) => {
var revisionData = mockData.resultset.revisions[i];
var userTokens = revisionData.author.split(/[<>]+/);
var name = userTokens[0];
var email = null;
if (userTokens.length > 1) email = userTokens[1];
var nameString = name;
if (email !== null) nameString += `: ${email}`;
expect(initial.outerHTML).toEqual($filter('initials')(name));
expect(initial.parentNode.title).toEqual(nameString);
});
});
it('renders an "...and more" link if the revision count is higher than the max display count of 20', () => {
mockData.resultset.revision_count = 21;
var component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
var revisionItems = component[0].querySelectorAll('li');
expect(revisionItems.length).toEqual(mockData.resultset.revisions.length + 1);
var lastItem = revisionItems[revisionItems.length - 1];
expect(lastItem.textContent).toEqual('\u2026and more');
expect(lastItem.querySelector('a i.fa.fa-external-link-square')).toBeDefined();
});
it('linkifies bugs IDs in the comments', () => {
var escapedComment = _.escape(mockData.resultset.revisions[0].comments.split('\n')[0]);
var linkifiedCommentText = $filter('linkifyBugs')(escapedComment);
var component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
var commentEm = component[0].querySelector('.revision-comment em');
expect(commentEm.innerHTML).toEqual(linkifiedCommentText);
});
it('marks the revision as backed out if the words "Back/Backed out" appear in the comments', () => {
var component, firstRevision;
mockData.resultset.revisions[0].comments = "Backed out changeset a6e2d96c1274 (bug 1322565) for eslint failure";
component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
firstRevision = component[0].querySelector('li .revision');
expect(firstRevision.getAttribute('data-tags').indexOf('backout')).not.toEqual(-1);
mockData.resultset.revisions[0].comments = "Back out changeset a6e2d96c1274 (bug 1322565) for eslint failure";
component = compile('<revisions repo="repo" resultset="resultset" />', mockData);
firstRevision = component[0].querySelector('li .revision');
expect(firstRevision.getAttribute('data-tags').indexOf('backout')).not.toEqual(-1);
});
});

16
tests/ui/vendor/react-with-addons.min.js поставляемый Normal file

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@ -223,7 +223,7 @@ input:focus::-moz-placeholder {
.label-initials {
display: inline-block;
margin-right: 0.2em;
margin-right: 0.5em;
padding: 0.2em 0.3em;
width: 2.5em;
border: 1px solid #999999;

Просмотреть файл

@ -119,6 +119,10 @@ fieldset[disabled] .btn-resultset:hover {
white-space: nowrap;
}
.revision-list .fa-external-link-square {
padding-left: 4px;
}
.revision {
font-size: 12px;
padding-top: 2px;

Просмотреть файл

@ -55,6 +55,8 @@
</div>
<th-notification-box></th-notification-box>
<script src="vendor/react/react.min.js"></script>
<script src="vendor/react/react-dom.min.js"></script>
<!-- build:js js/index.min.js -->
<script src="vendor/jquery-2.1.3.js"></script>
@ -86,6 +88,7 @@
<script src="js/directives/treeherder/resultsets.js"></script>
<script src="js/directives/treeherder/top_nav_bar.js"></script>
<script src="js/directives/treeherder/bottom_nav_panel.js"></script>
<script src="js/reactrevisions.js"></script>
<!-- Main services -->
<script src="js/services/main.js"></script>
<script src="js/services/buildapi.js"></script>
@ -138,43 +141,14 @@
<script src="js/filters.js"></script>
<!-- endbuild -->
<script src="vendor/ngReact/ngReact.min.js"></script>
<!-- build:dontbuild -->
<script src="js/config/local.conf.js"></script>
<!-- endbuild -->
<!-- Clone targets -->
<!-- Clone target for each revision -->
<script type="'text/ng-template'" id="revisionsClone.html">
<div class="clearfix"></div>
<li>
<span class="revision" data-tags="{{comment_tags}}">
<span class="revision-holder">
<a href="{{currentRepo.getRevisionHref(revision)}}"
title="Open revision {{revision}} on {{currentRepo.url}}"
ignore-job-clear-on-click>{{revision | limitTo: 12}}</a>
</span>
<span title="{{name}}: {{email}}">{{name|initials}}</span>
<span title="{{escaped_comment}}">
<span class="revision-comment">
<em>{{escaped_comment_linkified}}</em>
</span>
</span>
</span>
</li>
</script>
<!-- Clone target for "more" link for large revision sets -->
<script type="'text/ng-template'" id="pushlogRevisionsClone.html">
<li>
<a href="{{currentRepo.getPushLogHref(revision)}}"
ignore-job-clear-on-click
target="_blank"> ...and more
<i class="fa fa-external-link-square"></i>
</a>
</li>
</script>
<!-- Clone target for each result set -->
<script type="'text/ng-template'" id="resultsetClone.html">
<div class="clearfix"></div>
@ -206,7 +180,7 @@
<span class="disabled job-group" title="{{ name }}"
data-grkey="{{ grkey }}">
<button class="btn group-symbol"
ignore-job-clear-on-click>{{ symbol }}{{(tier) ?
data-ignore-job-clear-on-click>{{ symbol }}{{(tier) ?
'<span class="small text-muted">[tier ' + tier + ']</span>': ""}}
</button>
<span class="group-content">
@ -231,7 +205,7 @@
<script type="'text/ng-template'" id="jobBtnClone.html">
<button class="btn job-btn {{ btnClass }} {{ key }} {{ extraClasses }}"
data-jmkey="{{ key }}"
ignore-job-clear-on-click
data-ignore-job-clear-on-click
title="{{ title }}">{{ value }}</button>
</script>
@ -240,7 +214,7 @@
<button class="btn runnable-job-btn {{ key }} {{ extraClasses }}"
data-jmkey="{{ key }}"
data-buildername="{{ buildername }}"
ignore-job-clear-on-click
data-ignore-job-clear-on-click
title="{{ title }}">{{ value }}</button>
</script>

Просмотреть файл

@ -156,7 +156,7 @@ treeherderApp.controller('MainCtrl', [
$scope.clearJobOnClick = function(event) {
var element = event.target;
// Suppress for various UI elements so selection is preserved
var ignoreClear = element.hasAttribute("ignore-job-clear-on-click");
var ignoreClear = element.hasAttribute("data-ignore-job-clear-on-click");
if (!ignoreClear && !thPinboard.hasPinnedJobs()) {
$scope.closeJob();

Просмотреть файл

@ -6,13 +6,13 @@ treeherder.directive('thCloneJobs', [
'thServiceDomain', 'thResultStatusInfo', 'thEvents', 'thAggregateIds',
'thJobFilters', 'thResultStatusObject', 'ThResultSetStore',
'ThJobModel', 'linkifyBugsFilter', 'thResultStatus', 'thPlatformName',
'thNotify', '$timeout',
'thNotify', '$timeout', '$compile',
function(
$rootScope, $http, ThLog, thUrl, thCloneHtml,
thServiceDomain, thResultStatusInfo, thEvents, thAggregateIds,
thJobFilters, thResultStatusObject, ThResultSetStore,
ThJobModel, linkifyBugsFilter, thResultStatus, thPlatformName,
thNotify, $timeout){
thNotify, $timeout, $compile){
var $log = new ThLog("thCloneJobs");
@ -450,54 +450,16 @@ treeherder.directive('thCloneJobs', [
/**
* Add the list of revisions to the resultset
*/
var addRevisions = function(resultset, element){
var addRevisions = function(scope, element){
if (resultset.revisions.length > 0){
if (scope.resultset.revisions.length > 0){
var revisionInterpolator = thCloneHtml.get('revisionsClone').interpolator;
var ulEl = element.find('.revision-list');
var ulEl = element.find('ul');
_.extend(scope, { repo: $rootScope.currentRepo });
var revisionList = $compile('<revisions resultset="resultset" repo="repo"></revisions>')(scope);
$(ulEl).replaceWith(revisionList);
//make sure we're starting with an empty element
$(ulEl).empty();
var revision, revisionHtml, userTokens, i;
for (i=0; i<resultset.revisions.length; i++) {
revision = resultset.revisions[i];
revision.urlBasePath = $rootScope.urlBasePath;
revision.currentRepo = $rootScope.currentRepo;
userTokens = revision.author.split(/[<>]+/);
if (userTokens.length > 1) {
revision.email = userTokens[1];
}
revision.name = userTokens[0].trim();
// Only use the first line of the full commit message.
revision.escaped_comment = _.escape(revision.comments.split('\n')[0]);
revision.escaped_comment_linkified = linkifyBugsFilter(revision.escaped_comment);
// Parse the comment so we can tag things like backouts
var tags = "";
if (revision.escaped_comment.search("Backed out") >= 0 ||
revision.escaped_comment.search("Back out") >= 0) {
tags += "backout ";
}
revision.comment_tags = tags.trim();
revisionHtml = revisionInterpolator(revision);
ulEl.append(revisionHtml);
}
if (resultset.revision_count > resultset.revisions.length) {
var pushlogInterpolator = thCloneHtml.get('pushlogRevisionsClone').interpolator;
ulEl.append(pushlogInterpolator({
currentRepo: $rootScope.currentRepo,
revision: resultset.revision
}));
}
}
};
@ -1052,7 +1014,7 @@ treeherder.directive('thCloneJobs', [
tableInterpolator({ aggregateId:resultsetAggregateId })
);
addRevisions(scope.resultset, targetEl);
addRevisions(scope, targetEl);
element.append(targetEl);

Просмотреть файл

@ -49,7 +49,7 @@ treeherder.directive('thAuthor', function () {
},
template: '<span title="View resultsets for {{authorName}}: {{authorEmail}}">' +
'<a href="{{authorResultsetFilterUrl}}"' +
'ignore-job-clear-on-click>{{authorName}}</a></span>'
'data-ignore-job-clear-on-click>{{authorName}}</a></span>'
};
});

Просмотреть файл

@ -71,10 +71,10 @@ treeherder.filter('linkifyBugs', function() {
// Settings
var bug_title = 'bugzilla.mozilla.org';
var bug_url = '<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=$1" ' +
'data-bugid=$1 ' + 'title=' + bug_title + '>$1</a>';
'data-bugid="$1" ' + 'title="' + bug_title + '">$1</a>';
var pr_title = 'github.com';
var pr_url = '<a href="https://github.com/mozilla-b2g/gaia/pull/$1" ' +
'data-prid=$1 ' + 'title=' + pr_title + '>$1</a>';
'data-prid="$1" ' + 'title="' + pr_title + '">$1</a>';
if (bug_matches) {
// Separate passes to preserve prefix

122
ui/js/reactrevisions.js Normal file
Просмотреть файл

@ -0,0 +1,122 @@
'use strict';
var moreRevisionsLinkComponent = React.createClass({
displayName: 'pushlogRevisionComponent',
propTypes: {
href: React.PropTypes.string.isRequired
},
render() {
return React.DOM.li(
null,
React.DOM.a({
href: this.props.href,
'data-ignore-job-clear-on-click': true,
target: '_blank'
},
'\u2026and more',
React.DOM.i({ className: 'fa fa-external-link-square' })
)
);
}
});
var moreRevisionsLinkComponentFactory = React.createFactory(moreRevisionsLinkComponent);
var revisionItemComponent = React.createClass({
displayName: 'revisionItemComponent',
propTypes: {
revision: React.PropTypes.object.isRequired,
repo: React.PropTypes.object.isRequired,
linkifyBugsFilter: React.PropTypes.func.isRequired,
initialsFilter: React.PropTypes.func.isRequired
},
render() {
var email, name, userTokens, escapedComment, escapedCommentHTML, initialsHTML, tags;
userTokens = this.props.revision.author.split(/[<>]+/);
name = userTokens[0];
if (userTokens.length > 1) email = userTokens[1];
initialsHTML = { __html: this.props.initialsFilter(name) };
escapedComment = _.escape(this.props.revision.comments.split('\n')[0]);
escapedCommentHTML = { __html: this.props.linkifyBugsFilter(escapedComment) };
tags = "";
if (escapedComment.search("Backed out") >= 0 ||
escapedComment.search("Back out") >= 0) {
tags += "backout ";
}
tags = tags.trim();
return React.DOM.li(
{ className: 'clearfix' },
React.DOM.span({
className: 'revision',
'data-tags': tags
},
React.DOM.span(
{ className: 'revision-holder' },
React.DOM.a({
title: `Open revision ${this.props.revision.revision} on ${this.props.repo.url}`,
href: this.props.repo.getRevisionHref(this.props.revision.revision),
'data-ignore-job-clear-on-click': true
},
this.props.revision.revision.substring(0, 12)
)),
React.DOM.span({
title: `${name}: ${email}`,
dangerouslySetInnerHTML: initialsHTML
}),
React.DOM.span(
{ title: escapedComment },
React.DOM.span(
{ className: 'revision-comment' },
React.DOM.em({ dangerouslySetInnerHTML: escapedCommentHTML })
)
)
)
);
}
});
var revisionItemComponentFactory = React.createFactory(revisionItemComponent);
var revisionListComponent = React.createClass({
displayName: 'revisionListComponent',
propTypes: {
resultset: React.PropTypes.object.isRequired,
repo: React.PropTypes.object.isRequired,
$injector: React.PropTypes.object.isRequired
},
render() {
var repo = this.props.repo;
// Possible "...and more" link
var moreLink = null;
if (this.props.resultset.revision_count > this.props.resultset.revisions.length) {
moreLink = moreRevisionsLinkComponentFactory({
href: this.props.repo.getPushLogHref(this.props.resultset.revision)
});
}
var $injector = this.props.$injector;
return React.DOM.span(
{ className: 'revision-list col-xs-5' },
React.DOM.ul(
{ className: 'list-unstyled' },
this.props.resultset.revisions.map(function(item, i) {
return revisionItemComponentFactory({
initialsFilter: $injector.get('$filter')('initials'),
linkifyBugsFilter: $injector.get('$filter')('linkifyBugs'),
revision: item,
repo: repo,
key: i
});
}),
moreLink
)
);
}
});
treeherder.directive('revisions', function (reactDirective, $injector) {
return reactDirective(revisionListComponent, undefined, {}, {$injector});
});

Просмотреть файл

@ -47,15 +47,13 @@ treeherder.factory('thCloneHtml', [
function($interpolate) {
var cloneTemplateIds = [
'revisionsClone.html',
'resultsetClone.html',
'platformClone.html',
'jobTdClone.html',
'jobGroupClone.html',
'jobGroupCountClone.html',
'jobBtnClone.html',
'runnableJobBtnClone.html',
'pushlogRevisionsClone.html'
'resultsetClone.html',
'runnableJobBtnClone.html'
];
var templateId, templateName, templateTxt, i;

Просмотреть файл

@ -2,7 +2,7 @@
var treeherderApp = angular.module('treeherder.app',
['treeherder', 'ui.bootstrap', 'ngRoute',
'mc.resizer', 'angular-toArrayFilter']);
'mc.resizer', 'angular-toArrayFilter', 'react']);
treeherderApp.config(function($compileProvider, $routeProvider,
$httpProvider, $logProvider, $resourceProvider,

Просмотреть файл

@ -18,7 +18,7 @@
<span>
<a href="{{::revisionResultsetFilterUrl}}"
title="View only this resultset"
ignore-job-clear-on-click>{{::resultsetDateStr}}
data-ignore-job-clear-on-click>{{::resultsetDateStr}}
<span class="fa fa-external-link icon-superscript"></span></a> - </span>
<th-author author="{{::resultset.author}}"></th-author>
</span>
@ -28,24 +28,24 @@
<button class="btn btn-sm btn-resultset cancel-all-jobs-btn"
ng-attr-title="{{getCancelJobsTitle(resultset.revision)}}"
ng-show="currentRepo.repository_group.name == 'try' || user.is_staff"
ignore-job-clear-on-click
data-ignore-job-clear-on-click
ng-disabled="!canCancelJobs()"
ng-click="confirmCancelAllJobs()">
<span class="fa fa-times-circle cancel-job-icon dim-quarter"
ignore-job-clear-on-click></span>
data-ignore-job-clear-on-click></span>
</button>
<button class="btn btn-sm btn-resultset pin-all-jobs-btn"
title="Pin all available jobs in this resultset"
ignore-job-clear-on-click
data-ignore-job-clear-on-click
ng-click="pinAllShownJobs()">
<span class="glyphicon glyphicon-pushpin"
ignore-job-clear-on-click></span>
data-ignore-job-clear-on-click></span>
</button>
<button class="btn btn-sm btn-resultset trigger-new-jobs-btn"
title="Trigger new jobs"
ng-show="showTriggerButton()"
ng-disabled="!user.loggedin"
ignore-job-clear-on-click
data-ignore-job-clear-on-click
ng-click="triggerNewJobs()">
Trigger New Jobs
</button>

Просмотреть файл

@ -5,31 +5,31 @@
title="Action menu"
data-hover="dropdown"
data-delay="1000"
ignore-job-clear-on-click>
<span class="caret" ignore-job-clear-on-click></span>
data-ignore-job-clear-on-click>
<span class="caret" data-ignore-job-clear-on-click></span>
</button>
<!-- Menu contents -->
<ul class="dropdown-menu pull-right">
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
title="Add new jobs to this resultset"
href=""
ng-hide="!user.loggedin || resultset.isRunnableVisible"
ng-click="showRunnableJobs()">Add new jobs</a></li>
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
title="Hide Runnable Jobs"
href=""
ng-show="resultset.isRunnableVisible"
ng-click="deleteRunnableJobs()">Hide Runnable Jobs</a></li>
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
href="https://bugherder.mozilla.org/?cset={{::resultset.revision}}&amp;tree={{::repoName}}"
title="Use Bugherder to mark the bugs in this push">Mark with Bugherder</a></li>
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
href="https://secure.pub.build.mozilla.org/buildapi/self-serve/{{::repoName}}/rev/{{::resultset.revision}}">BuildAPI</a></li>
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
href=""
ng-show="user.is_staff"
ng-click="triggerMissingJobs(resultset.revision)">Trigger missing jobs</a></li>
<li><a target="_blank" ignore-job-clear-on-click
<li><a target="_blank" data-ignore-job-clear-on-click
href=""
ng-show="user.is_staff"
ng-click="triggerAllTalosJobs(resultset.revision)">Trigger all Talos jobs</a></li>