Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static/css/chopsui-normal.css b/static/css/chopsui-normal.css
new file mode 100644
index 0000000..981d12e
--- /dev/null
+++ b/static/css/chopsui-normal.css
@@ -0,0 +1,164 @@
+:root {
+ /* Subset of https://material.io/design/color/the-color-system.html */
+ --chops-red-50: #ffebee;
+ --chops-red-700: #d32f2f;
+ --chops-purple-50: #f3e5f5;
+ --chops-purple-700: #7b1fa2;
+ --chops-blue-50: #e3f2fd;
+ /*
+ Additional blue added on top of the 2014 Material Design palette because
+ blue 50 is too low contrast for backgrounds. Made from mixing blue 50
+ and blue 100.
+ */
+ --chops-blue-75: #d9edfc;
+ --chops-blue-100: #bbdefb;
+ --chops-blue-300: #64b5f6;
+ --chops-blue-700: #1976d2;
+ --chops-blue-900: #01579b;
+ --chops-green-50: #e8f5e9;
+ --chops-green-800: #2e7d32;
+ --chops-light-green-10: #f6fff5;
+ --chops-light-green-50: #f1f8e9;
+ --chops-yellow-50: #fffde7;
+ --chops-orange-50: #fff3e0;
+ --chops-orange-200: #ffcc80;
+ --chops-gray-50: #fafafa;
+ --chops-gray-200: #eee;
+ --chops-gray-300: #e0e0e0;
+ --chops-gray-400: #bdbdbd;
+ --chops-gray-500: #9e9e9e;
+ --chops-gray-600: #757575;
+ --chops-gray-700: #616161;
+ --chops-gray-800: #424242;
+ --chops-gray-850: #303030;
+ --chops-gray-900: #212121;
+ /* Making these variables makes it easier to add user-side scripts in a reasonable way. */
+ --chops-white: #ffffff;
+ --chops-black: #000000;
+
+ /* To make grays used for font styles and icons maintain consistent
+ * contrast ratios across colored backgrounds, we repesent them as pure black
+ * with opacity set. */
+ --chops-gray-700-alpha: hsla(0, 0%, 0%, 0.62);
+ --chops-gray-800-alpha: hsla(0, 0%, 0%, 0.74);
+ --chops-gray-900-alpha: hsla(0, 0%, 0%, 0.87);
+
+ --chops-blue-gray-25: #f1f3f4;
+ --chops-blue-gray-50: #eceff1; /* Similar to grimoire. */
+
+ --chops-primary-header-bg: var(--chops-white);
+ --chops-secondary-header-bg: var(--chops-blue-gray-25);
+ --chops-sidebar-bg: var(--chops-blue-gray-25);
+ --chops-page-bg: var(--chops-white);
+ --chops-footer-bg: transparent;
+ --chops-primary-icon-color: var(--chops-gray-700-alpha);
+
+ --chops-normal-border: 1px solid hsl(0, 0%, 85%);
+ /* Border color for situations when contrast is important. */
+ --chops-accessible-border: 1px solid var(--chops-gray-400);
+ --chops-radius: 6px;
+ --chops-shadow: none;
+
+ --chops-primary-font-color: var(--chops-gray-900-alpha);
+ --chops-font-family: 'Roboto', 'Noto', sans-serif;
+ --chops-link-color: var(--chops-primary-accent-color);
+ --chops-link-font-weight: 500;
+ --chops-light-accent-color: var(--chops-blue-300);
+ --chops-primary-accent-color: var(--chops-blue-700);
+ --chops-primary-accent-bg: var(--chops-blue-50);
+ --chops-primary-button-bg: var(--chops-primary-accent-color);
+ --chops-primary-button-color: var(--chops-white);
+ --chops-button-bg: var(--chops-gray-200);
+ --chops-button-color: var(--chops-black);
+ --chops-button-disabled-bg: var(--chops-gray-300);
+ --chops-button-disabled-color: var(--chops-gray-600);
+ --chops-button-border: none;
+ --chops-button-radius: 4px;
+ --chops-choice-bg: var(--chops-blue-gray-50);
+ --chops-choice-color: var(--chops-gray-600);
+ --chops-active-choice-bg: var(--chops-blue-75);
+ --chops-active-choice-color: var(--chops-primary-accent-color);
+ --chops-transition-time: 0.1s;
+
+ --chops-error-bubble-bg: var(--chops-red-50);
+ --chops-notice-bubble-bg: var(--chops-orange-50);
+ --chops-notice-border: 1px solid var(--chops-orange-200);
+ --chops-help-bubble-bg: var(--chops-blue-50);
+ --chops-field-error-color: var(--chops-red-700);
+ --chops-selected-bg: var(--chops-yellow-50);
+
+ --chops-card-heading-bg: var(--chops-secondary-header-bg);
+ --chops-card-details-bg: var(--chops-gray-50);
+ --chops-card-border: var(--chops-normal-border);
+ --chops-card-content-bg: var(--chops-white);
+
+ --chops-table-header-bg: var(--chops-secondary-header-bg);
+ --chops-table-row-bg: var(--chops-white);
+ --chops-table-divider: var(--chops-normal-border);
+
+ --chops-main-font-size: 13px;
+ --chops-large-font-size: 15px;
+ --chops-icon-font-size: 20px;
+
+ /* A few Monorail-specific CSS variables. */
+ --monorail-header-height: 44px;
+ --monorail-metadata-open-bg: var(--chops-light-green-10);
+ --monorail-metadata-closed-bg: var(--chops-sidebar-bg);
+}
+
+
+body {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ font-family: var(--chops-font-family);
+ line-height: 1.4;
+ font-size: var(--chops-main-font-size);
+ min-width: 300px;
+ background: var(--chops-page-bg);
+ color: var(--chops-primary-font-color);
+}
+
+/* Global styles for the EZT pages. */
+a {
+ color: var(--chops-link-color);
+ text-decoration: none;
+ font-weight: var(--chops-link-font-weight);
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Legacy CSS used by both the SPA and the EZT pages. */
+#footer {
+ clear: both;
+ text-align: right;
+ padding-top: 1em;
+ margin: 3.5em 0em;
+ color: var(--chops-gray-500);
+ background: var(--chops-footer-bg);
+}
+
+#footer a,
+#footer a:visited {
+ text-decoration: none;
+ margin-right: 2em;
+}
+
+#ac-list {
+ border: 1px solid var(--chops-gray-400);
+ background: var(--chops-white);
+ color: var(--chops-link-color);
+ padding: 2px;
+ z-index: 999;
+ max-height: 18em;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+#ac-list { font-size: 95%; }
+#ac-list tr { margin: 1px; cursor: pointer; padding: 0 10px; }
+#ac-list th { color: var(--chops-gray-850); text-align: left; }
+#ac-list .selected,
+#ac-list .selected td { background: var(--chops-active-choice-bg); }
+#ac-list td, #ac-list th { white-space: nowrap; padding-right: 22px}
diff --git a/static/css/d_sb.css b/static/css/d_sb.css
new file mode 100644
index 0000000..099d121
--- /dev/null
+++ b/static/css/d_sb.css
@@ -0,0 +1,181 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/* Style sheet for issue attachment source browsing pages. */
+
+/* List */
+#resultstable {table-layout:fixed}
+#resultstable div {white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
+
+/* Diffs */
+.diff pre {
+ margin:0;
+ padding:0;
+ white-space: pre-wrap;
+ white-space: -moz-pre-wrap;
+ white-space: -pre-wrap;
+ white-space: -o-pre-wrap;
+ word-wrap: break-word;
+}
+.diff th {padding:0 .6em; text-align:right; font-weight:normal; color:#666}
+.diff b {font-weight: normal}
+.diff .noline {background: #eee; border: 1px solid #888; border-width: 0 1px 0 1px}
+.diff .oldbackrm {background: #f88; border: 1px solid #a33; border-width: 0 1px 0 1px}
+.diff .oldbackeq {background: #ffd8d8; border: 1px solid #a33; border-width: 0 1px 0 1px}
+.diff .newbackadd {background: #9f9; border: 1px solid #3a3; border-width: 0 1px 0 1px}
+.diff .newbackeq {background: #ddf8cc; border: 1px solid #3a3; border-width: 0 1px 0 1px}
+.diff .oldrm {background: #f88;}
+.diff .oldeq {background: #ffd8d8;}
+.diff .newadd {background: #9f9;}
+.diff .neweq {background: #ddf8cc;}
+.diff .first td {border-top-width:1px}
+.diff .last td {border-bottom-width:1px}
+.header td {padding-bottom:.3em; text-align:center; font-family:arial, sans-serif}
+#controls {padding:.5em; white-space:nowrap}
+#controls td {padding:0 2px}
+#controls input, #controls select {font-size:93%; margin:0; padding:0}
+#controls form {margin:0; padding:0 1em}
+#controls a.revchoose {
+ text-decoration: none;
+ color: var(--chops-black);
+ padding: 4px;
+ border: 1px solid #ebeff9;
+}
+#controls a.revchoose:hover {
+ border: 1px inset var(--chops-white);
+}
+
+/* Property Diffs */
+.diff .firstseg {padding-left: 2px}
+.diff .lastseg {padding-right: 2px}
+.diff .samepropback {border: 1px solid var(--chops-black); border-width: 0 1px 0 1px}
+.diff td.nopropsep {border-bottom-width: 0px}
+.diff .propname td {font-size: 110%; font-weight: bold; padding: 1em 0.5em}
+.diff .bincontent {border-bottom-width: 1px; font-style: italic; font-size: 110%; padding: 0px 0.5em}
+.diff .propspace {font-size: 100%}
+.diff .sectiontitle {padding: 2em 0; font-style: italic; font-size: 110%}
+
+/* Meta bubble */
+#older, #props, #fileinfo {border-top:3px solid white; padding-top:6px; margin-top: 1em}
+#older pre {margin-top:4px; margin-left:1em}
+
+/* File */
+.fc pre, .fc td, .fc tr, .fc table, .fc tbody, #nums, #lines {padding:0; margin:0}
+.fc {position:relative; width:100%; min-height:30em}
+.fc table {border-collapse:collapse; margin:0; padding:0}
+#nums, #lines, #nums th, #lines th, #nums td, #lines td { vertical-align:top }
+pre {
+ font-family: Monaco, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Lucida Console', monospace;
+ font-size: 93%;
+}
+#nums {padding-right:.5em; width:3.7em}
+#nums td {text-align:right}
+#nums a {color:#77c; text-decoration:none}
+#nums tr:hover a {color:blue; text-decoration:underline}
+#nums td:target a {color:var(--chops-black); font-weight:bold}
+.sep {visibility:hidden; width:2px}
+#nums span { cursor: pointer; width: 14px; float: left; background-repeat: no-repeat; }
+#lines td {padding-left:4px;}
+
+/* Applies only to sb files and issue attachments */
+.fc #nums, .fc #lines {
+ padding-top: 0.5em;
+}
+.fc #lines {
+ border-left: 3px solid #ebeff9;
+}
+
+#log { position:absolute; top:2px; right:0; width:28em}
+#log p { font-size:120%; margin: 0 0 0.5em 0}
+#log pre { margin-top: 0.3em}
+
+/* IE Whitespace Fix */
+.prettyprint td.source {
+ white-space: pre-wrap;
+ white-space: -moz-pre-wrap;
+ white-space: -pre-wrap;
+ white-space: -o-pre-wrap;
+}
+
+/* Header */
+.src_nav {
+ height:1.2em;
+ padding-top:0.2em;
+}
+.src_crumbs {
+ padding:0;
+ margin:0;
+}
+#crumb_root {
+ padding:0.2em 0 0.2em 0.2em;
+ margin:0;
+}
+#crumb_links {
+ margin-top:0;
+ margin-right:0;
+ padding:0.2em 1px;
+}
+form.src_nav {
+ padding:0;
+ margin:0;
+ display: inline;
+}
+#src_nav_title {
+ margin-right: 0.5em;
+}
+
+.heading {
+ background:#c3d9ff;
+}
+.sp {
+ color:#555;
+}
+.sourcelabel {
+ margin-left: 20px;
+ white-space: nowrap;
+}
+.sourcelabel select {
+ font-size: 93%;
+}
+#contents {
+ display: none;
+}
+
+/* Branch detail and revision log message */
+pre.wrap {
+ white-space: pre-wrap;
+ white-space: -moz-pre-wrap;
+}
+
+.edit_icon {
+ width: 14px;
+ height: 14px;
+ padding-right: 4px;
+}
+
+/* Source editing */
+.CodeMirror-line-numbers {
+ margin: .4em;
+ padding-right: 0.3em;
+ font-size: 83%;
+ font-family: Monaco, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
+ color: #777;
+ text-align: right;
+ border-right: 1px solid #aaa;
+}
+.editbox {
+ border-color: #999 #ccc #ccc;
+ border-width: 1px;
+ border-style: solid;
+ background: var(--chops-white);
+}
+.pending_bubble {
+ background-color: #e5ecf9;
+}
+#pending {
+ padding: 2px 2px 2px 4px;
+}
diff --git a/static/css/d_updates_page.css b/static/css/d_updates_page.css
new file mode 100644
index 0000000..9d4c1f6
--- /dev/null
+++ b/static/css/d_updates_page.css
@@ -0,0 +1,260 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+.activity-stream-list h4 {
+ font-size: 100%;
+ font-weight: normal;
+ padding: 0;
+ margin: 0;
+ padding-left: 1em;
+ background: var(--chops-table-header-bg);
+ line-height: 160%;
+}
+ul.activity-stream {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+ul.activity-stream li {
+ margin: 0;
+ padding: 0.375em 0;
+ z-index: 0;
+ clear: both;
+}
+ul.activity-stream li {
+ border-bottom: var(--chops-normal-border);
+}
+ul.activity-stream span.date {
+ float: left;
+ width: 7.5em;
+ text-align: right;
+ color: #5f5f5f;
+ padding-right: 1em;
+ background-repeat: no-repeat;
+ background-position: 5px center;
+}
+ul.activity-stream span.below-more {
+ background-image: url(/static/images/plus.gif);
+ cursor: pointer;
+}
+ul.activity-stream li.click span.below-more {
+ background-image: url(/static/images/minus.gif);
+}
+ul.activity-stream span.content {
+ display: block;
+ overflow: hidden;
+ white-space: nowrap;
+}
+ul.activity-stream span.content span.highlight-column {
+ padding-right: 1em;
+}
+ul.activity-stream span.details-inline {
+ color: #676767;
+}
+ul.activity-stream span.details-inline pre {
+ display: inline;
+}
+ul.activity-stream span.details-inline div,
+ul.activity-stream span.details-inline span {
+ display: inline;
+}
+ul.activity-stream div.details-wrapper {
+ display: none;
+}
+ul.activity-stream li.click span.details-inline {
+ display: none;
+}
+ul.activity-stream li.click div.details-wrapper {
+ display: block;
+ overflow: hidden;
+}
+ul.activity-stream div.details {
+ color: #5f5f5f;
+ margin-top: 0.3em;
+ padding-top: 0.2em;
+ padding-bottom: 0.2em;
+ margin-left: 0.2em;
+ border-left: 0.3em solid #e5ecf9;
+ padding-left: 0.5em;
+ line-height: 130%;
+}
+ul.activity-stream div.details span.ot-logmessage,
+ul.activity-stream div.details span.ot-issue-comment,
+ul.activity-stream div.details span.ot-project-summary {
+ white-space: pre;
+}
+ul.activity-stream div.details a,
+ul.activity-stream span.details-inline a {
+ color: var(--chops-link-color);
+}
+a.showAll,
+a.hideAll {
+ color: var(--chops-link-color);
+}
+body.detailedInfo_hidden ul.activity-stream a.details {
+ color: var(--chops-link-color);
+ text-decoration: underline;
+ cursor: pointer;
+}
+ul.activity-stream div.details pre {
+ font-size: 110%;
+ line-height: 125%;
+ padding: 0;
+ margin: 0;
+}
+ul.activity-stream span.content a.ot-profile-link-1,
+ul.activity-stream span.content a.ot-project-link-1 {
+ color: var(--chops-link-color);
+}
+ul.activity-stream span.content a.ot-profile-link-2,
+ul.activity-stream span.content a.ot-project-link-2 {
+ color: var(--chops-link-color);
+}
+ul.activity-stream div.details span.ot-revlogs-br-1 {
+ display: block;
+ padding: 0;
+ margin: 0;
+}
+ul.activity-stream div.details span.ot-revlogs-br-2,
+ul.activity-stream div.details span.ot-issue-fields-br {
+ display: block;
+ padding: 0;
+ margin: 0.5em;
+}
+ul.activity-stream div.details span.ot-issue-field-wrapper,
+ul.activity-stream div.details span.ot-labels-field-wrapper {
+ font-family: arial, sans-serif;
+}
+ul.activity-stream span.details-inline span.ot-issue-field-wrapper,
+ul.activity-stream span.details-inline span.ot-labels-field-wrapper {
+ font-family: arial, sans-serif;
+}
+ul.activity-stream div.details span.ot-issue-field-name,
+ul.activity-stream div.details span.ot-labels-field-name {
+ font-weight: bold;
+}
+ul.activity-stream span.details-inline span.ot-issue-field-name,
+ul.activity-stream span.details-inline span.ot-labels-field-name {
+ font-weight: bold;
+}
+div.display-error {
+ font-style: italic;
+ text-align: center;
+ padding: 3em;
+}
+.results td a {
+ color: var(--chops-link-color);
+}
+.results td a:hover {
+ text-decoration: underline;
+}
+.results td a.closed_ref {
+ color: var(--chops-link-color);
+ text-decoration: line-through;
+}
+.results td {
+ cursor: auto;
+}
+.highlight-column {
+ overflow: hidden;
+ white-space: nowrap;
+ display: block;
+}
+
+/**
+ * Document container designed for fluid width scaling.
+ * Alternative g-doc- fixed-width classes are in gui-fixed.css.
+ */
+.g-doc {
+ width: 100%;
+ text-align: left;
+}
+
+/* For agents that support the pseudo-element selector syntax. */
+.g-section:after {
+ content: ".";
+ display: block;
+ height: 0;
+ clear: both;
+ visibility: hidden;
+}
+
+/* Disable the clear on nested sections so they'll actually nest. */
+.g-unit .g-section:after {
+ clear: none;
+}
+.g-section {
+ /* Helps with extreme float-drops in nested sections in IE 6 & 7. */
+ width: 100%;
+ /* So nested sections' background-color paints the full height. */
+ overflow: hidden;
+}
+
+/* Forces "hasLayout" for IE. This fixes the usual gamut of peekaboo bugs. */
+.g-section,
+.g-unit {
+ zoom: 1;
+}
+
+/* Used for splitting a template's units text-alignment to the outer edges. */
+.g-split .g-unit {
+ text-align: right;
+}
+.g-split .g-first {
+ text-align: left;
+}
+
+/* Document container designed for 1024x768 */
+.g-doc-1024 {
+ width: 73.074em;
+ *width: 71.313em;
+ min-width: 950px; /* min-width doesn't work in IE6 */
+ margin: 0 auto;
+ text-align: left;
+}
+/* Document container designed for 800x600 */
+.g-doc-800 {
+ width: 57.69em;
+ *width: 56.3em;
+ min-width: 750px; /* min-width doesn't work in IE6 */
+ margin: 0 auto;
+ text-align: left;
+}
+
+.g-tpl-160 .g-unit,
+.g-unit .g-tpl-160 .g-unit,
+.g-unit .g-unit .g-tpl-160 .g-unit,
+.g-unit .g-unit .g-unit .g-tpl-160 .g-unit {
+ margin: 0 0 0 8.5em;
+ width: auto;
+ float: none;
+}
+.g-unit .g-unit .g-unit .g-tpl-160 .g-first,
+.g-unit .g-unit .g-tpl-160 .g-first,
+.g-unit .g-tpl-160 .g-first,
+.g-tpl-160 .g-first {
+ margin: 0;
+ width: 8.5em;
+ float: left;
+}
+
+.g-tpl-300 .g-unit,
+.g-unit .g-tpl-300 .g-unit,
+.g-unit .g-unit .g-tpl-300 .g-unit,
+.g-unit .g-unit .g-unit .g-tpl-300 .g-unit {
+ margin: 0 0 0 19.5em;
+ width: auto;
+ float: none;
+}
+.g-unit .g-unit .g-unit .g-tpl-300 .g-first,
+.g-unit .g-unit .g-tpl-300 .g-first,
+.g-unit .g-tpl-300 .g-first,
+.g-tpl-300 .g-first {
+ margin: 0;
+ width: 19.5em;
+ float: left;
+}
diff --git a/static/css/ph_core.css b/static/css/ph_core.css
new file mode 100644
index 0000000..c8fc43b
--- /dev/null
+++ b/static/css/ph_core.css
@@ -0,0 +1,923 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+@charset "utf-8";
+
+body {
+ margin: 0 0 3px 0;
+ min-width: 768px;
+}
+
+#monobar {
+ background: var(--chops-primary-header-bg);
+ margin: 0;
+ padding: 0;
+}
+
+#monobar th {
+ white-space: nowrap;
+ vertical-align: middle;
+ font-weight: normal;
+}
+
+.sidebar {
+ background: var(--chops-sidebar-bg);
+ border: var(--chops-normal-border);
+ padding: 4px;
+}
+
+.padded {
+ padding: 4px 1em;
+}
+
+#monobar a#wordmark {
+ font-family: sans-serif;
+ font-variant: small-caps;
+ font-size: 140%;
+ font-weight: bold;
+ font-style: oblique;
+ color: #822;
+ letter-spacing: 1px;
+ text-decoration: none;
+}
+
+#thumbnail_box {
+ background-color: var(--chops-white);
+ vertical-align: middle;
+}
+
+#thumbnail_box a, #thumbnail_box img {
+ display: block;
+}
+
+.toptabs a:link, .toptabs a:visited {
+ color: #444;
+ padding: 0 .5em ;
+ text-decoration: none;
+}
+
+.toptabs a:hover {
+ color: var(--chops-link-color);
+ text-decoration: underline;
+}
+
+.toptabs a.active {
+ font-weight: bold;
+ color: var(--chops-black);
+ text-decoration: none;
+}
+
+#userbar {
+ text-align: right;
+}
+
+#userbar a {
+ color: var(--chops-black);
+}
+
+.subt {
+ background: var(--chops-secondary-header-bg);
+ margin: 0;
+ padding: 4px 1em;
+ border: var(--chops-normal-border);
+ border-width: 1px 0 1px 0;
+}
+
+a:link, a:focus {
+ color: var(--chops-link-color);
+}
+
+a:active {
+ color: red;
+}
+
+input[type="text"] {
+ border-color: #999 #ccc #ccc;
+ border-style: solid;
+ border-width: 1px;
+ padding: 3px 3px;
+}
+
+input[type=button], input[type=reset], input[type=submit], .buttonify {
+ font-size: 100%;
+ background: var(--chops-button-bg);
+ color: var(--chops-button-color) !important;
+
+ padding: 6.1px 10px;
+ margin-right: .6em;
+ border: var(--chops-button-border);
+ border-radius: var(--chops-button-radius);
+ box-shadow: var(--chops-shadow);
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.subt input[type=button], .subt input[type=reset], .subt input[type=submit], .subt .buttonify {
+ padding: 3px 6px;
+}
+
+input[type=submit], input[type=button].primary, a.primary {
+ background: var(--chops-primary-button-bg);
+ color: var(--chops-primary-button-color) !important;
+}
+
+input[type=submit].secondary {
+ background: var(--chops-button-bg);
+ color: var(--chops-button-color) !important;
+}
+
+input[type=submit]:disabled, input[type=button]:disabled {
+ background: var(--chops-button-disabled-bg);
+ color: var(--chops-button-disabled-color) !important;
+}
+
+.button_set {
+ float: right;
+ font-size: 95%;
+ display: flex;
+ margin-left: 2em;
+ margin-bottom: 5px; /* Offsets padding lost by flex+float. */
+}
+
+@-moz-document url-prefix() {
+ .buttonify {
+ padding: 2px 3px 2px 3px;
+ }
+}
+
+input[type=button]:hover, input[type=reset]:hover, input[type=submit]:hover, .buttonify:hover {
+ border-color: #666;
+ text-decoration: none !important;
+}
+
+.choice_chip {
+ padding: 4px 10px;
+ margin-left: 6px;
+ background: var(--chops-choice-bg);
+ color: var(--chops-choice-color);
+ border-radius: 50vh;
+ text-decoration: none;
+}
+
+.active_choice {
+ background: var(--chops-active-choice-bg);
+ color: var(--chops-active-choice-color);
+ font-weight: var(--chops-link-font-weight);
+}
+
+a.choice_chip, a.choice_chip:visited {
+ color: var(--chops-choice-color);
+}
+
+input[type=button]:active, input[type=reset]:active, input[type=submit]:active,
+input.primary:active, .buttonify:active {
+ background: var(--chops-gray-600);
+ color: var(--chops-white) !important;
+}
+
+textarea {
+ border-color: #999 #ccc #ccc;
+ border-style: solid;
+ border-width: 1px;
+}
+
+td td, th th, th td, td th {
+ font-size: 100%;
+}
+
+form {
+ padding: 0;
+ margin: 0;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Project tab bar. */
+.gtb {
+ background: var(--chops-white);
+ border-bottom: 1px solid #ccc;
+ padding: 5px 10px 0 5px;
+ white-space: nowrap;
+}
+
+.user_bar {
+ cursor: pointer;
+ float: right;
+ margin: 5px 15px 6px 10px;
+}
+
+.gtb .gtbc {
+ clear: left;
+}
+
+table {
+ border-collapse: separate;
+}
+
+.nowrap { white-space: nowrap; }
+.nowrapspan span { white-space: nowrap; }
+.derived { font-style: italic; }
+
+.bubble_bg {
+ background: #eee;
+ margin-bottom: 0.6em;
+}
+
+.bubble {
+ padding: 4px;
+}
+
+#bub {
+ padding: 0 1px 0 1px;
+}
+
+.bub-top {
+ margin: 0 2px 2px;
+}
+
+.bub-bottom {
+ margin: 2px 2px 0;
+}
+
+.drop-down-bub {
+ font-size: 80%;
+ margin-top: -1px;
+}
+
+
+h4 {
+ color: #222;
+ font-size: 16pt;
+ margin: .4em;
+ padding: 0;
+}
+
+.section {
+ margin: 0 4px 1.6em 4px;
+ padding:4px;
+}
+.section .submit {
+ margin: 8px;
+}
+
+#maincol {
+ padding:4px;
+ background: var(--chops-page-bg);
+}
+
+.isf a, .at a, .isf a:visited, .at a:visited {
+ color: var(--chops-link-color);
+ text-decoration: none;
+}
+
+.at span {
+ margin-right: 1em;
+ white-space: nowrap;
+}
+
+.isf a:hover, .at a:hover {
+ color: var(--chops-link-color);
+ text-decoration: underline;
+}
+
+.at {
+ padding-top: 6px;
+ padding-bottom: 3px;
+}
+
+.st1 .inst1 a,
+.st2 .inst2 a,
+.st3 .inst3 a,
+.st4 .inst4 a,
+.st5 .inst5 a,
+.st6 .inst6 a,
+.st7 .inst7 a,
+.st8 .inst8 a,
+.st9 .inst9 a {
+ color: var(--chops-black);
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.notice, .error {
+ font-weight: bold;
+ padding: 4px 16px;
+ border-radius: 4px;
+}
+
+.notice {
+ background: var(--chops-notice-bubble-bg);
+}
+
+.error {
+ background: var(--chops-error-bubble-bg);
+}
+
+.adminonly {
+ color: #a00;
+ font-style: italic;
+}
+
+.fielderror {
+ color: var(--chops-field-error-color);
+ font-weight: bold;
+ padding: 4px;
+}
+
+.tip, .help {
+ background: var(--chops-help-bubble-bg);
+ font-size: 92%;
+ margin: 5px;
+ padding: 6px;
+ border-radius: 6px;
+}
+
+.tip {
+ width: 14em;
+}
+
+.help {
+ width: 44em;
+}
+
+.x_icon::before {
+ content: "\00D7";
+}
+
+.x_icon {
+ text-decoration: none;
+ font-size: 130%;
+ color: #444 !important;
+ padding: 0 2px;
+ vertical-align:middle;
+}
+
+.x_icon:active {
+ color: var(--chops-white) !important;
+ background: #444;
+}
+
+/* Google standard */
+.gbh {
+ border-top: 1px solid #C9D7F1;
+ font-size: 1px;
+ height: 0;
+ position: absolute;
+ top: 24px;
+ width: 100%;
+}
+
+#pname {
+ font-size:300%;
+ margin: 0;
+ padding: 0;
+}
+
+#pname a,
+#pname a:visited {
+ text-decoration:none;
+ color: #666;
+}
+
+#project_summary_link {
+ text-decoration: none;
+ color: #444;
+}
+
+.vt td,
+.vt th,
+.vt {
+ vertical-align: top;
+}
+
+.indicator {
+ font-size: x-small;
+ color: var(--chops-link-color);
+}
+
+div.h4, table.h4 {
+ background-color: var(--chops-secondary-header-bg);
+ margin-bottom: 2px;
+ padding: 2px;
+ font-weight: bold;
+ position: relative;
+ margin-top: 2px;
+}
+
+.mainhdr {
+ background-color: #ebeff9;
+ border-bottom: 1px solid #6b90da;
+ font-weight: bold;
+ font-size: 133%;
+ padding: 2px;
+}
+
+.secondaryhdr {
+ background-color: #eee;
+ padding: 10px;
+ border-bottom: 1px solid #ddd;
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+}
+
+h1 {
+ font-size: x-large;
+ margin-top: 0px;
+}
+
+h2 {
+ font-size: large;
+}
+
+h3 {
+ font-size: medium;
+ background: #eee;
+ padding: 0.5ex 0.5em 0.5ex 0.5em;
+ margin-right: 2em;
+}
+
+img {
+ border: 0;
+}
+
+#user_bar {
+ text-align: right;
+ margin-bottom: 10px;
+}
+
+#user_bar a {
+ color: var(--chops-link-color);
+ text-decoration: none;
+}
+
+#header {
+ position: relative;
+ height: 55px;
+ padding-top: 6px;
+ margin-bottom: -9px;
+}
+
+#title {
+ margin-left: 171px;
+ background-color: #eee;
+ font-size: large;
+ font-weight: bold;
+ padding-left: 3px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+}
+
+.label { text-decoration: none; color: green !important; }
+.label:hover { text-decoration: underline; }
+
+.fieldvalue { text-decoration: none; }
+.fieldvalue:hover { text-decoration: underline; }
+
+.fieldvalue_url {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+.fieldvalue_url:after {
+ content: "\A";
+ white-space: pre;
+}
+
+#colcontrol {
+ padding: 5px;
+}
+
+.cue {
+ margin-top: -4px;
+ padding: 1px;
+ background: var(--chops-notice-bubble-bg);
+ border: 1px solid #f0c36d;
+}
+.cue td span {
+ font-size: 85%;
+ text-align: center;
+ padding: 0 1em;
+}
+
+.results tr td { border-bottom: var(--chops-table-divider); }
+.resultstable tr td { border-bottom: var(--chops-table-divider); }
+
+.results th, .results_lite th {
+ background: var(--chops-table-header-bg);
+ text-align: left;
+ padding: 3px;
+ border: 0;
+ border-right: 1px solid var(--chops-white);
+}
+.results th:last-child { border-right: 0; }
+
+.results th a, .results th a:visited {
+ color: var(--chops-link-color);
+ padding-right: 4px;
+ margin-right: 4px;
+}
+.results td { cursor: pointer }
+.results td { padding: 6px; }
+.results td a { color: var(--chops-black); text-decoration: none; }
+#project_list .results td { padding: 18px; }
+#project_list table.results td.id { text-align: left; }
+
+.results td.id a,
+.results td.project a,
+.results td.url a { color: var(--chops-link-color); white-space: nowrap; }
+.results td.id a:visited,
+.results td.project a:visited,
+.results td.url a:visited { color: purple; }
+.results td.id a:hover, .results td.project a:hover, .results td.url a:hover { color: red; text-decoration: underline; }
+table.results .hoverTarget:hover a { color: #009; }
+.results .label { font-size: 80% }
+.results .selected { background-color: var(--chops-selected-bg); }
+.results td tt { color: #999; font-style: italic; font-weight: bold; }
+.results .displayproperties { font-size: 80%; color: #666; }
+
+.results .grid .gridtile tr { border: 0; }
+.results .grid .gridtile td { border: 0; }
+
+.comptable.all .comprow { display: table-row; }
+.comptable.active .comprow { display: none; }
+.comptable.active .comprow.active { display: table-row; }
+.comptable.toplevel .comprow { display: none; }
+.comptable.toplevel .comprow.toplevel { display: table-row; }
+.comptable.toplevel .comprow.toplevel.deprecated { display: none; }
+.comptable.myadmin .comprow { display: none; }
+.comptable.myadmin .comprow.myadmin { display: table-row; }
+.comptable.mycc .comprow { display: none; }
+.comptable.mycc .comprow.mycc { display: table-row; }
+.comptable.deprecated .comprow { display: none; }
+.comptable.deprecated .comprow.deprecated { display: table-row; }
+
+/* The revision flipper. */
+.flipper { font-family: monospace; font-size: 120%; }
+.flipper ul { list-style-type: none; padding: 0; margin: 0em 0.3em; }
+.flipper b { margin: 0em 0.3em; }
+
+.closed .ifOpened { display: none }
+.closed .opened span.ifOpened { display: inline }
+.opened .ifClosed { display: none }
+.opened .closed span.ifClosed { display: inline }
+
+a.star {
+ text-decoration: none;
+ cursor: pointer;
+ display: inline-block;
+ font-size: 18px;
+}
+
+a.spamflag {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.h3 {
+ font-size: 130%;
+ font-weight: bolder;
+}
+input { padding-left: 1px; padding-right: 1px; }
+textarea { padding-left: 1px; padding-right: 1px; }
+
+.pagination { font-size: 100%; float: right; white-space: nowrap; }
+.pagination a { margin-left: 0.3em; margin-right: 0.3em; }
+
+.author { margin-bottom: 1em; }
+
+#searchtips { padding-left: 2em; }
+#searchtips p { margin-left: 2em; }
+
+.issueList .inIssueList span,
+.issueAdvSearch .inIssueAdvSearch a,
+.issueSearchTips .inIssueSearchTips a {
+ font-weight: bold;
+ text-decoration: none;
+ color: var(--chops-black);
+}
+
+iframe[frameborder="1"] {
+ border: 1px solid #999;
+}
+
+/* For project menu */
+.menuDiv {
+ margin-top: 5px;
+ border-color: #C9D7F1 #3366CC #3366CC #A2BAE7;
+ border-style: solid;
+ border-width: 1px;
+ z-index: 1001;
+ padding: 0;
+ width: 175px;
+ background: var(--chops-white);
+ overflow: hidden;
+}
+.menuDiv .menuText {
+ padding: 3px;
+ text-decoration: none;
+ background: var(--chops-white);
+}
+.menuDiv .menuItem {
+ color: var(--chops-link-color);
+ padding: 3px;
+ text-decoration: none;
+ background: var(--chops-white);
+}
+.menuDiv .menuItem:hover {
+ color: var(--chops-white);
+ background: #3366CC;
+}
+.menuDiv .categoryTitle {
+ padding-left: 1px;
+}
+.menuDiv .menuCategory,
+.menuDiv .categoryTitle {
+ margin-top: 4px;
+}
+.menuDiv .menuSeparator {
+ margin: 0 0.5em;
+ border: 0;
+ border-top: 1px solid #C9D7F1;
+}
+
+.hostedBy {
+ text-align: center;
+ vertical-align: center;
+}
+
+.fullscreen-popup {
+ position: fixed;
+ right: 4%;
+ left: 4%;
+ top: 5%;
+ max-height: 90%;
+ opacity: 0.85;
+ -moz-opacity: 0.85;
+ -khtml-opacity: 0.85;
+ filter: alpha(opacity=85);
+ -moz-border-radius: 10px;
+
+ background: var(--chops-black);
+ color: var(--chops-white);
+ text-shadow: var(--chops-black) 1px 1px 7px;
+
+ padding: 1em;
+ z-index: 10;
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
+
+/* Make links on this dark background a lighter blue. */
+.fullscreen-popup a {
+ color: #dd0;
+}
+
+div#keys_help th {
+ color: yellow;
+ text-align: left;
+}
+
+div#keys_help td {
+ font-weight: normal;
+ color: var(--chops-white);
+}
+
+td.shortcut {
+ text-align: right;
+}
+
+span.keystroke {
+ color: #8d0;
+ font-family: monospace;
+ font-size: medium;
+}
+
+.list {
+ background-color:var(--chops-white);
+ padding: 5px;
+}
+
+.list-foot {
+ background-color:var(--chops-white);
+ padding: 5px;
+ height: 20px;
+}
+
+.graytext {
+ color: #666;
+}
+
+.vspacer {
+ margin-top: 1em;
+}
+
+.hspacer {
+ margin-right: 1em;
+}
+
+.emphasis {
+ font-weight: bold;
+}
+
+.formrow {
+ vertical-align: top;
+ padding-bottom: .569em;
+ white-space: nowrap;
+ overflow: hidden;
+ padding-top: .2em;
+}
+
+.forminline {
+ display: inline-block;
+ vertical-align: top;
+}
+
+.formlabelgutter {
+ margin-top: 0.3em;
+ text-align: right;
+ vertical-align: top;
+ white-space: normal;
+ width: 13em;
+}
+
+.formlabel {
+ font-weight: bold;
+ text-align: right;
+}
+
+.forminputgutter {
+ margin-top: 0.3em;
+ text-align: left;
+ vertical-align: top;
+ white-space: normal;
+ width: 36em;
+}
+
+.forminput {
+ width: 100%;
+}
+
+.formshortinput {
+ width: 11em;
+}
+
+.formselectgutter {
+ margin-top: 0.3em;
+ text-align: left;
+ vertical-align: top;
+ white-space: normal;
+ width: 18em;
+}
+
+.formselect {
+ width: 18em;
+}
+
+.formqm {
+ margin-left: 0.25em;
+ margin-right: 0.25em;
+}
+
+.formerror {
+ color: #a00;
+ display: block;
+ text-align: left;
+}
+
+.tablerow {
+ vertical-align: top;
+ padding-bottom: .569em;
+ white-space: nowrap;
+ overflow: hidden;
+ padding-top: .2em;
+}
+
+.tablelabelgutter {
+ margin-top: 0.3em;
+ text-align: left;
+ vertical-align: top;
+ white-space: normal;
+ width: 10em;
+}
+
+.tablelabel {
+ font-weight: bold;
+ text-align: left;
+}
+
+/* Gecko */
+html>body .goog-inline-block {
+ display: -moz-inline-box; /* This is ignored by FF3 and later*/
+ display: inline-block; /* This is ignored by pre-FF3 Gecko */
+}
+
+/* Default rule */
+.goog-inline-block {
+ position: relative;
+ display: inline-block;
+}
+
+/* Pre-IE7 */
+* html .goog-inline-block {
+ display: inline;
+}
+
+/* IE7 */
+*:first-child+html .goog-inline-block {
+ display: inline;
+}
+
+#popular {
+ border: solid silver;
+ border-width: 1px 0 1px 0;
+ padding: 0.3em;
+ width: 40em;
+}
+
+#popular table {
+ width: 40em;
+}
+
+#popular td {
+ padding: 2px;
+ white-space: nowrap;
+}
+
+#intro {
+ background:#ada;
+ margin: 3em;
+ width: 52em;
+}
+
+.userlink_avail {
+ display: inline-block;
+ white-space: nowrap;
+}
+
+.availability_none {
+ font-weight: bold;
+ color: #FF1744;
+}
+
+.availability_unsure {
+ font-weight: bold;
+ color: #EF6C00;
+}
+
+.availability_never {
+ font-weight: bold;
+ color: #6A1B9A;
+}
+
+.availability_banned {
+ font-weight: bold;
+ color: var(--chops-black);
+}
+
+/* Just for screen readers. */
+.visually_hidden {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
+
+.not_styled_as_heading {
+ font-size: inherit !important;
+ font-weight: inherit !important;
+ display: inline !important;
+ background: inherit !important;
+ border: none !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+
+/* Launch gates table. */
+
+#launch-gates-table {
+ border-collapse: collapse;
+}
+#launch-gates-table td, #launch-gates-table th {
+ border: 1px solid #ddd;
+ padding: 4px;
+}
+#launch-gates-table tr:nth-child(even){background-color: #f2f2f2;}
+
+#launch-gates-table th {
+ text-align: left;
+ background-color: #6ec5ff;
+}
+
+input.unlink_account, input.incoming_invite {
+ font-size: 80%;
+}
diff --git a/static/css/ph_detail.css b/static/css/ph_detail.css
new file mode 100644
index 0000000..1b37d12
--- /dev/null
+++ b/static/css/ph_detail.css
@@ -0,0 +1,610 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+pre.prettyprint {
+ padding: 0.5em;
+ overflow: auto;
+ font-family: Monaco, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Lucida Console', monospace;
+ font-size: 93%;
+}
+
+.role_label {
+ background-color: var(--chops-gray-600);
+ border-radius: 3px;
+ color: var(--chops-white);
+ display: inline-block;
+ padding: 2px 4px;
+ font-size: 75%;
+ font-weight: bold;
+ line-height: 14px;
+ vertical-align: text-bottom;
+}
+
+.date {
+ margin-right: 1em;
+}
+
+.issuedescription pre {
+ margin: 0 8px;
+ padding: 0 8px;
+ max-width: 80em;
+}
+
+.issuecomment pre.issue_text {
+ margin: 4px 8px;
+}
+
+.codefont .issue_text {
+ font-family: monospace;
+ font-size: 12px;
+}
+
+.issuedescription pre, .issuecomment pre {
+ white-space: pre-wrap;
+ white-space: -moz-pre-wrap;
+ white-space: -pre-wrap;
+ white-space: -o-pre-wrap;
+ font-family: var(--chops-font-family);
+}
+
+.issuecomment {
+ margin: 16px 8px;
+}
+
+.issuedescription pre a, .issuecomment pre a {
+ word-wrap: break-word;
+ word-break: break-all;
+}
+
+.closed_ref { text-decoration: line-through }
+.rowmajor { width: 700px; }
+
+.rowmajor th {
+ text-align: right;
+ white-space: nowrap;
+}
+
+@media (min-width: 425px) {
+ #issue-main {
+ display: flex;
+ }
+ #left-part {
+ width: 10%;
+ min-width: 20em;
+ }
+ #right-part {
+ width: 90%;
+ margin: 0;
+ padding: 0;
+ border-bottom: 2px solid var(--chops-page-bg);
+ }
+ #meta-float, .issueheader {
+ position: sticky;
+ z-index: 1;
+ top: var(--monorail-header-height);
+ }
+}
+
+@media (max-width: 840px) {
+ #meta-float, #summary-float {
+ top: 0;
+ }
+}
+
+#left-part {
+ background: var(--monorail-metadata-open-bg);
+ border: var(--chops-normal-border);
+}
+
+.closed_colors #left-part {
+ background: var(--monorail-metadata-closed-bg);
+}
+
+#meta-float th { white-space: nowrap; }
+.labelediting input { margin: 0 3px 4px 0; }
+.labelediting input { color: #060; }
+.collapse .ifExpand { display: none }
+.expand .ifCollapse { display: none }
+.inplace input { width: 100%; }
+.inplace td { border: 0; }
+.issueheader {
+ background: var(--monorail-metadata-open-bg);
+ border: var(--chops-normal-border);
+ padding: 2px;
+ border-left: 0;
+}
+
+.closed_colors .issueheader {
+ background: var(--monorail-metadata-closed-bg);
+}
+
+#flipper-box {
+ float: right;
+ height: 3em;
+ padding: 4px;
+ border-left: var(--chops-normal-border);
+}
+
+#flipper-box div {
+ text-align: center;
+ padding: 3px;
+}
+
+.closed_colors {
+ -: var(--chops-link-color);
+}
+
+.closed_colors td.issueheader, .closed_colors td.issueheader a {
+ background: #888;
+}
+
+#spam_banner {
+ border: 1px solid red;
+ background: var(--chops-red-50);
+ padding: .5em;
+ color: var(--chops-black);
+ margin: 2px;
+}
+
+#spam_banner a {
+ color: var(--chops-link-color);
+}
+
+.issuepage { margin-top: 0; }
+.issuepage td { padding: 2px; }
+.issuecomment {
+ max-width: 80em;
+}
+
+.issuecommentheader {
+ background: var(--chops-card-heading-bg);
+ padding: 2px 3px 3px 3px;
+}
+
+.issuedescription pre, .issuecomment pre {
+ padding-top: 6px;
+}
+
+.issuedescription pre b, .issuecomment pre b {
+ font-size: 110%;
+ font-weight: bolder;
+ padding: 3px 0 3px 0;
+}
+
+.issue_text:focus {
+ outline: 0;
+}
+
+.author { padding-left: 4px; }
+
+.ichcommands a {
+ color: var(--chops-gray-600);
+ text-decoration: none;
+}
+
+.issueheader .ichcommands a {
+ color: #555;
+}
+
+.issuecommentbody:focus {
+ outline: 0;
+}
+
+#issue_meta_details {
+ font-size: 95%;
+ vertical-align: top;
+ padding: 1em 5px 5px 5px;
+}
+
+#meta-float td, #meta-float td div, #meta-float div.widemeta {
+ max-width: 14em;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+}
+#meta-float td.widemeta, #meta-float td.widemeta div, #meta-float div.widemeta {
+ max-width: 20em;
+}
+
+.meta-floatheader {
+ padding: 0 5px;
+ position: relative;
+ min-width: 14em;
+}
+
+.issueheader a.material-icons {
+ padding: 0 5px;
+ text-decoration: none;
+ color: grey;
+ float: right;
+}
+
+.closed_colors #meta-float {
+ background: var(--monorail-metadata-closed-bg);
+}
+
+#meta-float table td, #meta-float table th {
+ margin: 0;
+ padding: 0;
+ padding-top: 5px;
+}
+
+.rel_issues a { white-space: nowrap; }
+
+.issue_restrictions {
+ padding: 2px 4px;
+ background-color: #fed;
+ min-width: 14em;
+ border: 2px solid var(--chops-white);
+}
+
+.issue_restrictions .restrictions_header {
+ padding: 0 0 2px 0;
+ text-align: center;
+ font-weight: bold;
+}
+
+.issue_restrictions ul {
+ padding: 0 2px;
+ margin: 0;
+ list-style: none;
+}
+
+.issue_restrictions .other_restriction {
+ white-space: nowrap;
+}
+
+.lock_grey {
+ background: no-repeat url(/static/images/lock.png);
+ width: 15px;
+ height: 16px
+}
+
+.updates {
+ background: var(--chops-card-details-bg);
+}
+
+.updates table {
+ width: 100%;
+ font-size: 90%;
+ padding: 4px;
+}
+
+.fakelink {
+ color: var(--chops-link-color);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.undef { color: #666; }
+table.advquery {
+ border: var(--chops-card-border);
+ border-radius: var(--chops-radius);
+ box-shadow: var(--chops-shadow);
+}
+
+table.advquery td {
+ white-space: nowrap;
+ padding: 2px;
+}
+
+.focus td { background: var(--chops-card-heading-bg); }
+
+.eg {
+ color: #666;
+ font-size: 90%;
+}
+
+#submit { font-weight: bold; }
+div td .novel { color: #430; }
+div td .blockingsubmit { color: #a03; }
+div td .exclconflict { color: #a03; }
+div td .questionmark { color: #a03; }
+.issuecomment .delcom { background: #e8e8e8; }
+.numberentry { text-align: right; }
+
+.rollovercontrol { display: none; }
+.rolloverzone:hover .rollovercontrol { display: inline; }
+
+td u {
+ margin-left: .3em;
+ color: var(--chops-link-color);
+ cursor: pointer;
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+td u:hover { text-decoration: underline; }
+#peopledetail input { margin-bottom: 2px; }
+#perm_defs { margin-top: 1em; }
+#perm_defs th { text-align:left; }
+
+#perm_defs td {
+ vertical-align:bottom;
+ padding-left: 1em;
+}
+
+.attachments { width:33%; margin-left: .7em;}
+.attachments table {
+ background: var(--chops-card-details-bg);
+ padding: 4px;
+ margin: 8px;
+}
+.attachments table tr td { padding: 0; margin: 0; font-size: 95%; }
+.preview { border: 2px solid #c3d9ff; padding: 1px; }
+.preview:hover { border: 2px solid blue; }
+.label { white-space: nowrap; }
+
+.cursor_on .author {
+ background: url(/static/images/show-arrow.gif) no-repeat 2px;
+}
+
+/* For Popup dialog boxes*/
+
+#update-issues-hotlists, #transfer-ownership-container, #remove-self-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 10;
+}
+
+#update-issues-hotlists-dialog, #transfer-ownership-dialog, #remove-self-dialog {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--chops-white);
+ border: 3px solid #333;
+ padding: 1em;
+ max-height: 75%;
+ width: 75%;
+ max-width: 30em;
+ overflow-y: auto;
+}
+
+/* Issue Peek Feature */
+
+#infobubble {
+ position: absolute;
+ display: none;
+ border: 1px solid #666;
+ padding: 3px 5px 5px 5px;
+ background: #ebeff9;
+}
+
+#peekarea {
+ min-height: 30em;
+ font-size: 95%;
+ background: var(--chops-white);
+}
+
+.perms_EditIssue #peekarea {
+ min-height: 36.4em;
+}
+
+#issuesummary {
+ width: 300px;
+ max-width: 300px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+td.rowwidgets { padding: 2px 2px 0 7px; }
+.cursor_on td.rowwidgets {
+ background-image: url(/static/images/show-arrow.gif);
+ background-repeat: no-repeat;
+ background-position: 2px;
+}
+
+.loading {
+ background-image: url(/static/images/spin_16.gif);
+ background-repeat: no-repeat;
+ background-position: 2px;
+ padding: 4px 20px;
+}
+
+#peekheading {
+ background: #ebeff9;
+ font-size:140%;
+ padding:2px 2px 0; overflow-x: hidden;
+ white-space:nowrap;
+}
+
+.peek #meta-float, .peek #issuecomments {
+ height: 28em;
+ max-height: 28em;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scroll: auto;
+}
+
+#hc_controls { float: right; }
+#hc_controls a.paginate { margin-left: 1px; }
+#hc_controls a.close { margin-left: 3px; }
+
+#infobuttons {
+ background: var(--chops-white);
+ /* for IE */
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f1f1f1');
+ /* for webkit browsers */
+ background: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f1f1f1));
+ /* for firefox 3.6+ */
+ background: -moz-linear-gradient(top, #fff, #f1f1f1);
+ border-top: 1px solid #ccc;
+ white-space:nowrap;
+}
+
+#infobuttons td {
+ padding: 0;
+}
+
+.custom_field_value_menu {
+ width: 20em;
+}
+
+.enum_checkbox {
+ display: inline-block;
+ width: 24%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ background: #f8f8f8;
+}
+
+.cue.scrim {
+ position: fixed;
+ z-index: 1;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgb(0,0,0);
+ background-color: rgba(0,0,0,0.4);
+}
+
+#privacy_dialog {
+ background: #fefefe;
+ border: 1px solid #888;
+ border-radius: 4px;
+ margin: 15% auto;
+ padding: 20px;
+ width: 80%;
+ max-width: 40em;
+}
+
+#privacy_dialog .actions {
+ margin-top: 2em;
+ text-align: right;
+ font-weight: bold;
+}
+
+#privacy_dialog .actions a {
+ text-decoration: none;
+ margin-left: 2em;
+}
+
+#show-ranks, #hide-ranks, #add-issue-to-hotlist{
+ color: #555;
+ cursor: pointer;
+}
+
+.rel_issues:hover #show-ranks, #hide-ranks:hover, #add-issue-to-hotlist:hover {
+ text-decoration: underline;
+}
+
+#blocked-scrim {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.4);
+ z-index: 10;
+}
+
+#blocked-table {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--chops-white);
+ border: 3px solid #333;
+ padding: 1em;
+ max-height: 75%;
+ width: 75%;
+ overflow-y: auto;
+}
+
+#blocked-rank {
+ display: table;
+ width: 100%;
+}
+
+#blocked-rank .closed a {
+ text-decoration: line-through;
+}
+
+.drag_item[draggable=true] {
+ position: relative;
+}
+
+.drag_item[draggable=false] {
+ cursor: wait;
+}
+
+.gripper {
+ color: var(--chops-primary-icon-color);
+ cursor: grab;
+ opacity: 0;
+}
+
+.drag_item[draggable=true]:hover .gripper {
+ opacity: 1;
+}
+
+.drag_container th, .drag_container td {
+ padding: 0.5em;
+}
+
+.drag_container th {
+ color: var(--chops-link-color);
+ background: var(--chops-table-header-bg);
+ text-align: left;
+}
+
+.drag_item.top td {
+ border-top: 2px solid #888;
+}
+
+.drag_item.bottom td {
+ border-bottom: 2px solid #888;
+}
+
+.component-suggestion {
+ display: inline-block;
+ cursor: pointer;
+ padding: 0.25em;
+ margin: 0.25em;
+ border: 1px solid #ddd;
+ border-radius: 0.25em;
+ background-color: #e3e9ff;
+}
+
+#preview_filterrules_area {
+ color: #430;
+ margin-top: 1em;
+}
+
+#preview_filterrules_area div {
+ margin-left: 1em;
+}
+
+#preview_filterrules_area div span {
+ font-style: italic;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+}
+
+#preview_filterrules_warnings, #preview_filterrules_errors {
+ margin-top: 3px;
+}
+#preview_filterrules_warnings ul, #preview_filterrules_errors ul {
+ margin-top: 0;
+ list-style-type: none;
+}
+
+#preview_filterrules_errors ul {
+ color: red;
+}
+
+#searchtips p {
+ max-width: 60em;
+}
diff --git a/static/css/ph_list.css b/static/css/ph_list.css
new file mode 100644
index 0000000..645d8c1
--- /dev/null
+++ b/static/css/ph_list.css
@@ -0,0 +1,188 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+.popup {
+ display: none;
+ background: var(--chops-white);
+ border: 2px solid #bbb;
+ border-width: 0 2px 2px 1px;
+ position: absolute;
+ padding: 2px;
+}
+
+.popup a { text-decoration: none; cursor: pointer; }
+.popup td, .popup th {
+ font-size: 90%;
+ font-weight: normal;
+ text-align: left;
+ cursor: pointer;
+}
+.popup td { color: var(--chops-link-color); padding: 2px; }
+.popup tr:hover { background: #f9edbe; }
+.subpopup { border-width: 1px 2px 2px 1px; }
+
+.group_row td {
+ background: var(--chops-table-header-bg);
+ cursor: pointer;
+}
+
+div.gridtile td.id {
+ width: 5em;
+ text-align: left;
+}
+
+div.gridtile {
+ border: 2px solid #f1f1f1;
+ border-radius: 6px;
+ padding: 1px;
+ background: var(--chops-white);
+}
+
+tr.grid td {
+ border-right: var(--chops-table-divider);
+}
+
+.results .grid th {
+ border-top: 1px solid var(--chops-white);
+}
+
+tr.grid .idcount {
+ text-align: left;
+}
+
+.results th a.dotdotdot {
+ text-decoration: none;
+ margin-right: 0;
+ padding-right: 0;
+}
+
+tr.grid .idcount a, .results .id a {
+ color: var(--chops-link-color);
+}
+
+tr.grid .idcount a {
+ margin-right: 0.6em;
+}
+
+div.gridtile {
+ width: 10em;
+ float: left;
+ margin: 2px;
+}
+
+div.gridtile table, div.projecttile table {
+ width: 100%;
+ table-layout: fixed;
+}
+
+div.gridtile td, div.projecttile td {
+ border: 0;
+ padding: 2px;
+ overflow: hidden;
+}
+
+div.gridtile td div {
+ height: 5.5ex;
+ font-size: 90%;
+ line-height: 100%;
+}
+
+div.gridtile td.status {
+ font-size: 90%;
+ text-align: right;
+ width: 70%;
+}
+
+div.projecttile {
+ width: 14em;
+ height: 90px;
+ margin: 0 1em 2em 1em;
+ float: left;
+ padding: 1px;
+ border: 2px solid #c3d9ff;
+ border-radius: 6px;
+ }
+
+div.projecttile:hover {
+ background: #f1f1f1;
+}
+
+
+.hide_col_0 .col_0, .hide_col_1 .col_1, .hide_col_2 .col_2, .hide_col_3 .col_3,
+.hide_col_4 .col_4, .hide_col_5 .col_5, .hide_col_6 .col_6,
+.hide_col_7 .col_7, .hide_col_8 .col_8, .hide_col_9 .col_9,
+.hide_col_10 .col_10, .hide_col_11 .col_11, .hide_col_12 .col_12,
+.hide_col_13 .col_13, .hide_col_14 .col_14, .hide_col_15 .col_15,
+.hide_col_16 .col_16, .hide_col_17 .col_17, .hide_col_18 .col_18,
+.hide_col_19 .col_19, .hide_col_20 .col_20 { display: none; }
+
+.hide_col_0 .popup span.col_0, .hide_col_1 .popup span.col_1,
+.hide_col_2 .popup span.col_2, .hide_col_3 .popup span.col_3,
+.hide_col_4 .popup span.col_4, .hide_col_4 .popup span.col_4,
+.hide_col_5 .popup span.col_5, .hide_col_6 .popup span.col_6,
+.hide_col_7 .popup span.col_7, .hide_col_8 .popup span.col_8,
+.hide_col_9 .popup span.col_9, .hide_col_10 .popup span.col_10,
+.hide_col_11 .popup span.col_11, .hide_col_12 .popup span.col_12,
+.hide_col_13 .popup span.col_13, .hide_col_14 .popup span.col_14,
+.hide_col_14 .popup span.col_14, .hide_col_15 .popup span.col_15,
+.hide_col_16 .popup span.col_16, .hide_col_17 .popup span.col_17,
+.hide_col_18 .popup span.col_18, .hide_col_19 .popup span.col_19,
+.hide_col_20 .popup span.col_20 { display: inline; color: var(--chops-white); }
+
+.hide_col_0 .popup tr:hover span.col_0,
+.hide_col_1 .popup tr:hover span.col_1,
+.hide_col_2 .popup tr:hover span.col_2,
+.hide_col_3 .popup tr:hover span.col_3,
+.hide_col_4 .popup tr:hover span.col_4,
+.hide_col_5 .popup tr:hover span.col_5,
+.hide_col_6 .popup tr:hover span.col_6,
+.hide_col_7 .popup tr:hover span.col_7,
+.hide_col_8 .popup tr:hover span.col_8,
+.hide_col_9 .popup tr:hover span.col_9,
+.hide_col_10 .popup tr:hover span.col_10,
+.hide_col_11 .popup tr:hover span.col_11,
+.hide_col_12 .popup tr:hover span.col_12,
+.hide_col_13 .popup tr:hover span.col_13,
+.hide_col_14 .popup tr:hover span.col_14,
+.hide_col_15 .popup tr:hover span.col_15,
+.hide_col_16 .popup tr:hover span.col_16,
+.hide_col_17 .popup tr:hover span.col_17,
+.hide_col_18 .popup tr:hover span.col_18,
+.hide_col_19 .popup tr:hover span.col_19,
+.hide_col_20 .popup tr:hover span.col_20 { color: var(--chops-white); }
+
+
+.table_title {
+ font-weight: bold;
+}
+
+.contentarea {
+ position: relative;
+ margin-bottom: 1em;
+}
+
+#resultstable td {
+ padding-right: 1em;
+}
+
+.labels a:link { color: #080; }
+.labels a:visited { color: #080; }
+.labels a:active { color: #f00; }
+.name { margin-top: 2ex; font-size: 120%; }
+
+.results tr td a.directlink { visibility: hidden; }
+.results tr:hover td a.directlink {
+ visibility: visible;
+ color: grey;
+}
+
+.results .id { text-align: right; }
+#resultstable .id { text-align: right; }
+#projecttable .id { text-align: left; }
+#starredtable .id { text-align: left; }
+#archivedtable .id { text-align: left; }
+#usergrouptable .id { text-align: left; }
diff --git a/static/css/ph_mobile.css b/static/css/ph_mobile.css
new file mode 100644
index 0000000..82c1ada
--- /dev/null
+++ b/static/css/ph_mobile.css
@@ -0,0 +1,141 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+@media (max-width: 425px) {
+
+ body {
+ min-width: 0; /* get rid of hardcoded width */
+ }
+
+
+ /* Top navigation bar */
+
+ #monobar .toptabs {
+ display: none; /* hide most of the options to save some space */
+ }
+
+ #userbar {
+ padding: 5px;
+ }
+
+ #userbar > span {
+ display: inline-flex;
+ flex-wrap: wrap;
+ }
+
+
+ /* Search toolbar */
+
+ .subt {
+ padding: 5px;
+ }
+
+ .subt .inIssueEntry, .subt .inIssueList {
+ display: block;
+ margin: 10px 0 !important;
+ }
+
+ .subt label[for="searchq"], .subt label[for="can"], #can {
+ display: none; /* hide some labels and search scope helper field to save some space */
+ }
+
+
+ /* Main content */
+
+ #maincol > div > form > table > tbody > tr {
+ display: flex;
+ flex-direction: column;
+ }
+
+ #maincol > div > form > table > tbody > tr > td {
+ display: block;
+ }
+
+ #maincol table.rowmajor {
+ display: flex;
+ flex-direction: column;
+ width: auto; /* get rid of hardcoded width */
+ max-width: 100%;
+ }
+
+ #maincol table.rowmajor tbody {
+ flex-grow: 1;
+ }
+
+ #maincol table.rowmajor tr {
+ display: flex;
+ flex-direction: column;
+ }
+
+ #maincol table.rowmajor tr > th {
+ text-align: left;
+ }
+
+ #maincol table.rowmajor tr > td {
+ display: block;
+ width: 90%;
+ }
+
+ #maincol input[type="button"],
+ #maincol input[type="submit"],
+ #maincol select,
+ #maincol textarea {
+ font-size: 100%;
+ width: 100%;
+ margin-bottom: 16px;
+ }
+
+ #maincol input[type="button"],
+ #maincol input[type="submit"] {
+ padding: 10px;
+ }
+
+ #maincol .labelediting input {
+ max-width: 19%;
+ }
+
+ #maincol div.tip {
+ display: none;
+ }
+
+ #maincol .enum_checkbox {
+ width: 31%;
+ padding: 3px;
+ }
+
+
+ /* Others */
+
+ #footer {
+ display: flex;
+ margin: 0 5px 5px 5px ;
+ text-align: left;
+ }
+
+ #attachprompt {
+ display: block;
+ padding: 10px 0;
+ }
+
+ input[type="button"],
+ input[type="submit"],
+ a.buttonify { /* make all types of buttons easier to click */
+ padding: 5px;
+ }
+
+ table#meta-container,
+ table#meta-container > tbody > tr,
+ table#meta-container > tbody > tr> td {
+ display: block;
+ width: 100%;
+ }
+
+ #blocked-table {
+ width: 90%;
+ }
+
+}
diff --git a/static/css/prettify.css b/static/css/prettify.css
new file mode 100644
index 0000000..d44b3a2
--- /dev/null
+++ b/static/css/prettify.css
@@ -0,0 +1 @@
+.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
\ No newline at end of file
diff --git a/static/images/button-bg.gif b/static/images/button-bg.gif
new file mode 100644
index 0000000..cd2d728
--- /dev/null
+++ b/static/images/button-bg.gif
Binary files differ
diff --git a/static/images/chromium.svg b/static/images/chromium.svg
new file mode 100644
index 0000000..7ed1fb8
--- /dev/null
+++ b/static/images/chromium.svg
@@ -0,0 +1,277 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ version="1.1"
+ width="256"
+ height="256"
+ id="svg3039"
+ inkscape:version="0.47 r22583"
+ sodipodi:docname="Chromium_11_Logo.svg">
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="1280"
+ inkscape:window-height="750"
+ id="namedview6"
+ showgrid="false"
+ inkscape:zoom="1.4142136"
+ inkscape:cx="372.87473"
+ inkscape:cy="103.791"
+ inkscape:window-x="-8"
+ inkscape:window-y="-8"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg3039" />
+ <metadata
+ id="metadata3045">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs3043">
+ <linearGradient
+ id="linearGradient3803">
+ <stop
+ style="stop-color:#d7def0;stop-opacity:1;"
+ offset="0"
+ id="stop3805" />
+ <stop
+ id="stop3811"
+ offset="0.5"
+ style="stop-color:#ffffff;stop-opacity:1" />
+ <stop
+ style="stop-color:#d5def0;stop-opacity:1"
+ offset="1"
+ id="stop3807" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3776"
+ inkscape:collect="always">
+ <stop
+ id="stop3778"
+ offset="0"
+ style="stop-color:#b2cde9;stop-opacity:1" />
+ <stop
+ id="stop3780"
+ offset="1"
+ style="stop-color:#c4dbee;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3750">
+ <stop
+ id="stop3752"
+ offset="0"
+ style="stop-color:#d0e2f1;stop-opacity:1" />
+ <stop
+ style="stop-color:#cadef0;stop-opacity:1"
+ offset="0.85580856"
+ id="stop3756" />
+ <stop
+ id="stop3754"
+ offset="1"
+ style="stop-color:#95bee3;stop-opacity:1" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3708">
+ <stop
+ style="stop-color:#658db6;stop-opacity:1"
+ offset="0"
+ id="stop3710" />
+ <stop
+ id="stop3716"
+ offset="0.76777935"
+ style="stop-color:#527fab;stop-opacity:1;" />
+ <stop
+ style="stop-color:#4071a0;stop-opacity:1"
+ offset="1"
+ id="stop3712" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3698">
+ <stop
+ style="stop-color:#96d0e1;stop-opacity:1"
+ offset="0"
+ id="stop3700" />
+ <stop
+ id="stop3706"
+ offset="0.67819428"
+ style="stop-color:#89b7e1;stop-opacity:1" />
+ <stop
+ style="stop-color:#699dd3;stop-opacity:1"
+ offset="1"
+ id="stop3702" />
+ </linearGradient>
+ <linearGradient
+ inkscape:collect="always"
+ id="linearGradient3647">
+ <stop
+ style="stop-color:#3b79bc;stop-opacity:1;"
+ offset="0"
+ id="stop3649" />
+ <stop
+ style="stop-color:#94b8e0;stop-opacity:1"
+ offset="1"
+ id="stop3651" />
+ </linearGradient>
+ <linearGradient
+ inkscape:collect="always"
+ id="linearGradient3588">
+ <stop
+ style="stop-color:#ffffff;stop-opacity:1"
+ offset="0"
+ id="stop3590" />
+ <stop
+ style="stop-color:#000000;stop-opacity:0;"
+ offset="1"
+ id="stop3592" />
+ </linearGradient>
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3588"
+ id="radialGradient3594"
+ cx="-118.77966"
+ cy="121.49152"
+ fx="-118.77966"
+ fy="121.49152"
+ r="25.491526"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="matrix(0.02177942,-0.95743591,0.97872327,0.02221687,-235.0993,5.0684454)" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3647"
+ id="linearGradient3653"
+ x1="-397.81323"
+ y1="149.18764"
+ x2="-397.55933"
+ y2="51.355946"
+ gradientUnits="userSpaceOnUse" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3698"
+ id="radialGradient3704"
+ cx="-383.2746"
+ cy="217.91029"
+ fx="-383.2746"
+ fy="217.91029"
+ r="59.401995"
+ gradientTransform="matrix(-1.2861568,-0.08596317,0.11453678,-1.7136762,-425.01982,469.50099)"
+ gradientUnits="userSpaceOnUse" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3708"
+ id="radialGradient3714"
+ cx="-123.5"
+ cy="-11.570732"
+ fx="-123.5"
+ fy="-11.570732"
+ r="95.627118"
+ gradientTransform="matrix(-0.00756512,0.55751399,-1.0314585,-0.01398286,113.23967,103.212)"
+ gradientUnits="userSpaceOnUse" />
+ <radialGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3750"
+ id="radialGradient3748"
+ cx="-94.87291"
+ cy="165.27281"
+ fx="-94.87291"
+ fy="165.27281"
+ r="60.481357"
+ gradientTransform="matrix(0.81293878,1.6998003,-2.1519091,1.0291615,564.39485,118.47915)"
+ gradientUnits="userSpaceOnUse" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3776"
+ id="linearGradient3774"
+ x1="162.07127"
+ y1="85.239708"
+ x2="220.76114"
+ y2="78.875748"
+ gradientUnits="userSpaceOnUse"
+ gradientTransform="translate(3.3917128,7.418629)" />
+ <linearGradient
+ inkscape:collect="always"
+ xlink:href="#linearGradient3803"
+ id="linearGradient3809"
+ x1="-382.04123"
+ y1="37.280548"
+ x2="-381.39438"
+ y2="165.56691"
+ gradientUnits="userSpaceOnUse" />
+ </defs>
+ <path
+ sodipodi:type="arc"
+ style="fill:url(#radialGradient3594);fill-opacity:1;fill-rule:nonzero;stroke:none"
+ id="path2814"
+ sodipodi:cx="-118.23729"
+ sodipodi:cy="122.57627"
+ sodipodi:rx="25.491526"
+ sodipodi:ry="25.491526"
+ d="m -92.745764,122.57627 a 25.491526,25.491526 0 1 1 -50.983056,0 25.491526,25.491526 0 1 1 50.983056,0 z"
+ transform="matrix(4.680851,0,0,4.7978723,685.10478,-449.69946)" />
+ <path
+ style="fill:url(#linearGradient3774);fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 232.17258,88.120422 c 0,15.673918 -19.79135,34.931518 -45.84395,34.931518 -26.0526,0 -59.92241,-16.08123 -59.92241,-31.755152 0,-15.673924 21.11981,-28.38015 47.17241,-28.38015 19.90254,0 46.36122,18.293224 56.45971,20.3521 0.79179,1.710571 1.36862,2.925087 2.13424,4.851684 z"
+ id="path3655"
+ sodipodi:nodetypes="cssscc" />
+ <path
+ style="fill:#2e5c91;fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 38.822019,65.971523 c 12.38148,-9.610993 35.314514,-1.245318 51.289554,19.334679 15.975027,20.579998 17.694937,51.065068 5.31349,60.676058 -12.38147,9.61099 -34.17571,-5.29155 -50.15074,-25.87156 -12.20392,-15.72181 -4.05062,-41.19089 -8.61646,-50.430553 0.61589,-1.122052 1.381696,-2.456607 2.164156,-3.708624 z"
+ id="path3655-4-8"
+ sodipodi:nodetypes="cssscc" />
+ <path
+ style="fill:url(#radialGradient3714);fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 230.04347,83.261765 c -7.0081,-0.03265 -61.07025,0.289575 -107.66568,0.0654 -17.371,5.108098 -31.704627,13.258827 -39.181777,29.154945 -5.33639,-4.54237 -40.74576,-42.215609 -44.40678,-46.440684 31.38983,-41.648805 74.528017,-45.559321 82.915257,-45.559321 8.38724,0 70.64407,-8.631855 108.33898,62.77966 z"
+ id="path3596"
+ sodipodi:nodetypes="ccccsc" />
+ <path
+ style="fill:#699dd3;fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 117.12454,243.96815 c -12.49835,-9.45851 -14.5752,-36.93927 1.14635,-57.71356 15.72155,-20.77428 41.03582,-34.94753 53.53417,-25.48904 12.49834,9.4585 7.44792,38.96701 -8.27364,59.74129 -12.01027,15.87024 -35.4911,16.88498 -43.22681,23.69505 -1.23894,-0.0455 -1.95523,-0.0605 -3.18007,-0.23374 z"
+ id="path3655-4"
+ sodipodi:nodetypes="cssscc" />
+ <path
+ style="fill:url(#radialGradient3748);fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 120.3032,244.20103 c 3.58354,-6.02268 28.85859,-52.8991 52.69131,-92.9389 4.41104,-17.56095 5.34663,-33.64185 -4.5584,-48.14993 6.62173,-2.29412 58.23852,-13.976353 63.73684,-14.987686 19.9656,48.180076 1.44992,87.338276 -2.80522,94.565966 -4.25515,7.22768 -28.40179,65.25666 -109.06453,61.51055 z"
+ id="path3596-1"
+ sodipodi:nodetypes="ccccsc" />
+ <path
+ style="fill:url(#radialGradient3704);fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 36.696853,69.642524 c 3.46858,6.089612 30.72312,52.780196 53.77852,93.272576 13.094367,12.50527 27.684997,19.48512 45.191737,18.03328 -1.2738,6.89113 -16.62898,57.75037 -18.4638,63.03126 -51.756237,-6.42158 -76.669777,-41.85476 -80.854757,-49.1233 -4.18497,-7.26855 -42.7297502,-56.91452 0.3483,-125.213816 z"
+ id="path3596-1-7"
+ sodipodi:nodetypes="ccccsc" />
+ <path
+ transform="matrix(0.77294737,0,0,0.77619098,435.90647,53.275706)"
+ style="fill:url(#linearGradient3653);fill-opacity:1;stroke:url(#linearGradient3809);stroke-width:10.07013607;stroke-miterlimit:4;stroke-opacity:1"
+ d="m -338.44068,101.42373 c 0,32.65032 -26.46832,59.11864 -59.11865,59.11864 -32.65032,0 -59.11864,-26.46832 -59.11864,-59.11864 0,-32.650327 26.46832,-59.118646 59.11864,-59.118646 32.65033,0 59.11865,26.468319 59.11865,59.118646 z"
+ id="path3645" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 163.54619,108.89582 c 18.52979,17.09836 16.03302,29.55794 10.0625,44 -3.10892,-22.25001 -2.34478,-32.42697 -10.0625,-44 z"
+ id="rect3782"
+ sodipodi:nodetypes="ccc" />
+ <path
+ style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
+ d="m 101.42092,173.63924 c -22.645593,-14.47335 -29.809884,-45.71983 -8.813354,-62.99032 -10.847561,19.77514 -6.225429,32.39863 8.813354,62.99032 z"
+ id="rect3782-4"
+ sodipodi:nodetypes="ccc" />
+</svg>
diff --git a/static/images/favicon.ico b/static/images/favicon.ico
new file mode 100644
index 0000000..4597d7f
--- /dev/null
+++ b/static/images/favicon.ico
Binary files differ
diff --git a/static/images/lock.png b/static/images/lock.png
new file mode 100644
index 0000000..916f2b0
--- /dev/null
+++ b/static/images/lock.png
Binary files differ
diff --git a/static/images/minus.gif b/static/images/minus.gif
new file mode 100644
index 0000000..5595adf
--- /dev/null
+++ b/static/images/minus.gif
Binary files differ
diff --git a/static/images/monorail.ico b/static/images/monorail.ico
new file mode 100644
index 0000000..4597d7f
--- /dev/null
+++ b/static/images/monorail.ico
Binary files differ
diff --git a/static/images/pagination-first.png b/static/images/pagination-first.png
new file mode 100644
index 0000000..4ee7f31
--- /dev/null
+++ b/static/images/pagination-first.png
Binary files differ
diff --git a/static/images/pagination-last.png b/static/images/pagination-last.png
new file mode 100644
index 0000000..0dea95d
--- /dev/null
+++ b/static/images/pagination-last.png
Binary files differ
diff --git a/static/images/pagination-next.png b/static/images/pagination-next.png
new file mode 100644
index 0000000..8c8f937
--- /dev/null
+++ b/static/images/pagination-next.png
Binary files differ
diff --git a/static/images/pagination-prev.png b/static/images/pagination-prev.png
new file mode 100644
index 0000000..ac97b8a
--- /dev/null
+++ b/static/images/pagination-prev.png
Binary files differ
diff --git a/static/images/paperclip.png b/static/images/paperclip.png
new file mode 100644
index 0000000..34464c2
--- /dev/null
+++ b/static/images/paperclip.png
Binary files differ
diff --git a/static/images/plus.gif b/static/images/plus.gif
new file mode 100644
index 0000000..116ce91
--- /dev/null
+++ b/static/images/plus.gif
Binary files differ
diff --git a/static/images/show-arrow.gif b/static/images/show-arrow.gif
new file mode 100644
index 0000000..7864453
--- /dev/null
+++ b/static/images/show-arrow.gif
Binary files differ
diff --git a/static/images/spin_16.gif b/static/images/spin_16.gif
new file mode 100644
index 0000000..73a6a86
--- /dev/null
+++ b/static/images/spin_16.gif
Binary files differ
diff --git a/static/images/tearoff_icon.gif b/static/images/tearoff_icon.gif
new file mode 100644
index 0000000..c23734e
--- /dev/null
+++ b/static/images/tearoff_icon.gif
Binary files differ
diff --git a/static/js/framework/clientmon.js b/static/js/framework/clientmon.js
new file mode 100644
index 0000000..aa6dc0a
--- /dev/null
+++ b/static/js/framework/clientmon.js
@@ -0,0 +1,51 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+(function(window) {
+ 'use strict';
+
+ // This code sets up a reporting mechanism for uncaught javascript errors
+ // to the server. It reports at most every THRESHOLD_MS milliseconds and
+ // each report contains error signatures with counts.
+
+ let errBuff = {};
+ let THRESHOLD_MS = 2000;
+
+ function throttle(fn) {
+ let last, timer;
+ return function() {
+ let now = Date.now();
+ if (last && now < last + THRESHOLD_MS) {
+ clearTimeout(timer);
+ timer = setTimeout(function() {
+ last = now;
+ fn.apply();
+ }, THRESHOLD_MS + last - now);
+ } else {
+ last = now;
+ fn.apply();
+ }
+ };
+ }
+ let flushErrs = throttle(function() {
+ let data = {errors: JSON.stringify(errBuff)};
+ CS_doPost('/_/clientmon.do', null, data);
+ errBuff = {};
+ });
+
+ window.addEventListener('error', function(evt) {
+ let signature = evt.message;
+ if (evt.error instanceof Error) {
+ signature += '\n' + evt.error.stack;
+ }
+ if (!errBuff[signature]) {
+ errBuff[signature] = 0;
+ }
+ errBuff[signature] += 1;
+ flushErrs();
+ });
+})(window);
diff --git a/static/js/framework/env.js b/static/js/framework/env.js
new file mode 100644
index 0000000..baf19cb
--- /dev/null
+++ b/static/js/framework/env.js
@@ -0,0 +1,73 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Defines the type of the CS_env Javascript object
+ * provided by the Codesite server.
+ *
+ * This is marked as an externs file so that any variable defined with a
+ * CS.env type will not have its properties renamed.
+ * @externs
+ */
+
+/** Codesite namespace object. */
+var CS = {};
+
+/**
+ * Javascript object holding basic information about the current page.
+ * This is defined as an interface so that we can use CS.env as a Closure
+ * type name, but it will never be implemented; rather, it will be
+ * made available on every page as the global object CS_env (see
+ * codesite/templates/demetrius/header.ezt).
+ *
+ * The type of the CS_env global object will actually be one of
+ * CS.env, CS.project_env, etc. depending on the page
+ * rendered by the server.
+ *
+ * @interface
+ */
+CS.env = function() {};
+
+/**
+ * Like relativeBaseUrl, but a full URL preceded by http://code.google.com
+ * @type {string}
+ */
+CS.env.prototype.absoluteBaseUrl;
+
+/**
+ * Path to versioned static assets (mostly js and css).
+ * @type {string}
+ */
+CS.env.prototype.appVersion;
+
+/**
+ * Request token for the logged-in user, or null for the anonymous user.
+ * @type {?string}
+ */
+CS.env.prototype.token;
+
+/**
+ * Email address of the logged-in user, or null for anon.
+ * @type {?string}
+ */
+CS.env.prototype.loggedInUserEmail;
+
+/**
+ * Url to the logged-in user's profile, or null for anon.
+ * @type {?string}
+ */
+CS.env.prototype.profileUrl;
+
+/**
+ * CS.env specialization for browsing project pages.
+ * @interface
+ * @extends {CS.env}
+ */
+CS.project_env = function() {};
+
+/** @type {string} */
+CS.project_env.prototype.projectName;
diff --git a/static/js/framework/externs.js b/static/js/framework/externs.js
new file mode 100644
index 0000000..a0375a1
--- /dev/null
+++ b/static/js/framework/externs.js
@@ -0,0 +1,25 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/** @type {CS.env} */
+var CS_env;
+
+// Exported functions must be mentioned in this externs file so that JSCompiler
+// will allow exporting functions by writing '_hideID = CS_hideID'.
+var _hideID;
+var _showID;
+var _hideEl;
+var _showEl;
+var _showInstead;
+var _toggleHidden;
+var _toggleCollapse;
+var _CS_dismissCue;
+var _CS_updateProjects;
+var _CP_checkProjectName;
+var _TKR_toggleStar;
+var _TKR_toggleStarLocal;
+var _TKR_syncStarIcons;
diff --git a/static/js/framework/framework-ajax.js b/static/js/framework/framework-ajax.js
new file mode 100644
index 0000000..038c4c3
--- /dev/null
+++ b/static/js/framework/framework-ajax.js
@@ -0,0 +1,153 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+
+/**
+ * @fileoverview AJAX-related helper functions.
+ */
+
+
+var DEBOUNCE_THRESH_MS = 2000;
+
+
+/**
+ * Simple debouncer to handle text input. Don't try to hit the server
+ * until the user has stopped typing for a few seconds. E.g.,
+ * var debouncedKeyHandler = debounce(keyHandler);
+ * el.addEventListener('keyup', debouncedKeyHandler);
+ */
+function debounce(func, opt_threshold_ms) {
+ let timeout;
+ return function() {
+ let context = this, args = arguments;
+ let later = function() {
+ timeout = null;
+ func.apply(context, args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, opt_threshold_ms || DEBOUNCE_THRESH_MS);
+ };
+}
+
+
+/**
+ * Builds a POST string from a parameter dictionary.
+ * @param {Array|Object} args: parameters to encode. Either an object
+ * mapping names to values or an Array of doubles containing [key, value].
+ * @return {string} encoded POST data.
+ */
+function CS_postData(args) {
+ let params = [];
+
+ if (args instanceof Array) {
+ for (var key in args) {
+ let inputValue = args[key];
+ let name = inputValue[0];
+ let value = inputValue[1];
+ if (value !== undefined) {
+ params.push(name + '=' + encodeURIComponent(String(value)));
+ }
+ }
+ } else {
+ for (var key in args) {
+ params.push(key + '=' + encodeURIComponent(String(args[key])));
+ }
+ }
+
+ params.push('token=' + encodeURIComponent(window.prpcClient.token));
+
+ return params.join('&');
+}
+
+/**
+ * Helper for an extremely common kind of XHR: a POST with an XHRF token
+ * where we silently ignore server or connectivity errors. If the token
+ * has expired, get a new one and retry the original request with the new
+ * token.
+ * @param {string} url request destination.
+ * @param {function(event)} callback function to be called
+ * upon successful completion of the request.
+ * @param {Object} args parameters to encode as POST data.
+ */
+function CS_doPost(url, callback, args) {
+ window.prpcClient.ensureTokenIsValid().then(() => {
+ let xh = XH_XmlHttpCreate();
+ XH_XmlHttpPOST(xh, url, CS_postData(args), callback);
+ });
+}
+
+
+/**
+ * Helper function to strip leading junk characters from a JSON response
+ * and then parse it into a JS constant.
+ *
+ * The reason that "}])'\n" is prepended to the response text is that
+ * it makes it impossible for a hacker to hit one of our JSON servlets
+ * via a <script src="..."> tag and do anything with the result. Even
+ * though a JSON response is just a constant, it could be passed into
+ * hacker code by tricks such as overriding the array constructor.
+ */
+function CS_parseJSON(xhr) {
+ return JSON.parse(xhr.responseText.substr(5));
+}
+
+
+/**
+ * Promise-based version of CS_parseJSON using the fetch API.
+ *
+ * Sends a GET request to a JSON endpoint then strips the XSSI prefix off
+ * of the response before resolving the promise.
+ *
+ * Args:
+ * url (string): The URL to fetch.
+ * Returns:
+ * A promise, resolved when the request returns. Also be sure to call
+ * .catch() on the promise (or wrap in a try/catch if using async/await)
+ * if you don't want errors to halt script execution.
+ */
+function CS_fetch(url) {
+ return fetch(url, {credentials: 'same-origin'})
+ .then((res) => res.text())
+ .then((rawResponse) => JSON.parse(rawResponse.substr(5)));
+}
+
+
+/**
+ * After we refresh the form token, we need to actually submit the form.
+ * formToSubmit keeps track of which form the user was trying to submit.
+ */
+var formToSubmit = null;
+
+/**
+ * If the form token that was generated when the page was served has
+ * now expired, then request a refreshed token from the server, and
+ * don't submit the form until after it arrives.
+ */
+function refreshTokens(event, formToken, formTokenPath, tokenExpiresSec) {
+ if (!window.prpcClient.constructor.isTokenExpired(tokenExpiresSec)) {
+ return;
+ }
+
+ formToSubmit = event.target;
+ event.preventDefault();
+ const message = {
+ token: formToken,
+ tokenPath: formTokenPath,
+ };
+ const refreshTokenPromise = window.prpcClient.call(
+ 'monorail.Sitewide', 'RefreshToken', message);
+
+ refreshTokenPromise.then((freshToken) => {
+ let tokenFields = document.querySelectorAll('input[name=token]');
+ for (let i = 0; i < tokenFields.length; ++i) {
+ tokenFields[i].value = freshToken.token;
+ }
+ if (formToSubmit) {
+ formToSubmit.submit();
+ }
+ });
+}
diff --git a/static/js/framework/framework-ajax_test.js b/static/js/framework/framework-ajax_test.js
new file mode 100644
index 0000000..c5218d3
--- /dev/null
+++ b/static/js/framework/framework-ajax_test.js
@@ -0,0 +1,37 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Tests for framework-ajax.js.
+ */
+
+var CS_env;
+
+function setUp() {
+ CS_env = {'token': 'd34db33f'};
+}
+
+function testPostData() {
+ assertEquals(
+ 'token=d34db33f',
+ CS_postData({}));
+ assertEquals(
+ 'token=d34db33f',
+ CS_postData({}, true));
+ assertEquals(
+ '',
+ CS_postData({}, false));
+ assertEquals(
+ 'a=5&b=foo&token=d34db33f',
+ CS_postData({a: 5, b: 'foo'}));
+
+ let unescaped = {};
+ unescaped['f oo?'] = 'b&ar';
+ assertEquals(
+ 'f%20oo%3F=b%26ar',
+ CS_postData(unescaped, false));
+}
diff --git a/static/js/framework/framework-cues.js b/static/js/framework/framework-cues.js
new file mode 100644
index 0000000..2c620a1
--- /dev/null
+++ b/static/js/framework/framework-cues.js
@@ -0,0 +1,38 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Simple functions for dismissible on-page help ("cues").
+ */
+
+/**
+ * Dimisses the cue. This both updates the DOM and hits the server to
+ * record the fact that the user has dismissed it, so that it won't
+ * be shown again.
+ *
+ * If no security token is present, only the DOM is updated and
+ * nothing is recorded on the server.
+ *
+ * @param {string} cueId The identifier of the cue to hide.
+ * @return {boolean} false to cancel any event.
+ */
+function CS_dismissCue(cueId) {
+ let cueElements = document.querySelectorAll('.cue');
+ for (let i = 0; i < cueElements.length; ++i) {
+ cueElements[i].style.display = 'none';
+ }
+
+ if (CS_env.token) {
+ window.prpcClient.call(
+ 'monorail.Users', 'SetUserPrefs',
+ {prefs: [{name: cueId, value: 'true'}]});
+ }
+ return false;
+}
+
+// Exports
+_CS_dismissCue = CS_dismissCue;
diff --git a/static/js/framework/framework-display.js b/static/js/framework/framework-display.js
new file mode 100644
index 0000000..9213e82
--- /dev/null
+++ b/static/js/framework/framework-display.js
@@ -0,0 +1,191 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by the Project Hosting to control the display of
+ * elements on the page, rollovers, and popup menus.
+ *
+ * Most of these functions are extracted from dit-display.js
+ */
+
+
+/**
+ * Hide the HTML element with the given ID.
+ * @param {string} id The HTML element ID.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_hideID(id) {
+ $(id).style.display = 'none';
+ return false;
+}
+
+
+/**
+ * Show the HTML element with the given ID.
+ * @param {string} id The HTML element ID.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_showID(id) {
+ $(id).style.display = '';
+ return false;
+}
+
+
+/**
+ * Hide the given HTML element.
+ * @param {Element} el The HTML element.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_hideEl(el) {
+ el.style.display = 'none';
+ return false;
+}
+
+
+/**
+ * Show the given HTML element.
+ * @param {Element} el The HTML element.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_showEl(el) {
+ el.style.display = '';
+ return false;
+}
+
+
+/**
+ * Show one element instead of another. That is to say, show a new element and
+ * hide an old one. Usually the element is the element that the user clicked
+ * on with the intention of "expanding it" to access the new element.
+ * @param {string} newID The ID of the HTML element to show.
+ * @param {Element} oldEl The HTML element to hide.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_showInstead(newID, oldEl) {
+ $(newID).style.display = '';
+ oldEl.style.display = 'none';
+ return false;
+}
+
+/**
+ * Toggle the open/closed state of a section of the page. As a result, CSS
+ * rules will make certain elements displayed and other elements hidden. The
+ * section is some HTML element that encloses the element that the user clicked
+ * on.
+ * @param {Element} el The element that the user clicked on.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_toggleHidden(el) {
+ while (el) {
+ if (el.classList.contains('closed')) {
+ el.classList.remove('closed');
+ el.classList.add('opened');
+ return false;
+ }
+ if (el.classList.contains('opened')) {
+ el.classList.remove('opened');
+ el.classList.add('closed');
+ return false;
+ }
+ el = el.parentNode;
+ }
+}
+
+
+/**
+ * Toggle the expand/collapse state of a section of the page. As a result, CSS
+ * rules will make certain elements displayed and other elements hidden. The
+ * section is some HTML element that encloses the element that the user clicked
+ * on.
+ * TODO(jrobbins): eliminate redundancy with function above.
+ * @param {Element} el The element that the user clicked on.
+ * @return {boolean} Always returns false to cancel the browser event
+ * if used as an event handler.
+ */
+function CS_toggleCollapse(el) {
+ while (el) {
+ if (el.classList.contains('collapse')) {
+ el.classList.remove('collapse');
+ el.classList.add('expand');
+ return false;
+ }
+ if (el.classList.contains('expand')) {
+ el.classList.remove('expand');
+ el.classList.add('collapse');
+ return false;
+ }
+ el = el.parentNode;
+ }
+}
+
+
+/**
+ * Register a function for mouse clicks on the results table. We
+ * listen on the table to avoid adding 1000 individual listeners on
+ * the cells. This is needed because some browsers (now including
+ * Chrome) do not generate click events for mouse buttons other than
+ * the primary mouse button. Chrome and Firefox generate auxclick
+ * events, but Edge does not.
+ */
+
+function CS_addClickListener(tableEl, handler) {
+ function maybeClick(event) {
+ const target = getTargetFromEvent(event);
+
+ const inLink = target.tagName == 'A' || target.parentNode.tagName == 'A';
+
+ if (inLink && !target.classList.contains('computehref')) {
+ // The <a> elements already have the correct hrefs.
+ return;
+ }
+ if (event.button == 2) {
+ // User is trying to open a context menu, not trying to navigate.
+ return;
+ }
+
+ let td = target;
+ while (td && td.tagName != 'TD' && td.tagName != 'TH') {
+ td = td.parentNode;
+ }
+ if (td.classList.contains('rowwidgets')) {
+ // User clicked on a checkbox.
+ return;
+ }
+ // User clicked on an issue ID link or text or cell.
+ event.preventDefault();
+ handler(event);
+ }
+ tableEl.addEventListener('click', maybeClick);
+ tableEl.addEventListener('auxclick', maybeClick);
+}
+
+function getTargetFromEvent(event) {
+ let target = event.target || event.srcElement;
+ if (target.shadowRoot) {
+ // Find the element within the shadowDOM.
+ const path = event.path || event.composedPath();
+ target = path[0];
+ }
+ return target;
+}
+
+
+// Exports
+_hideID = CS_hideID;
+_showID = CS_showID;
+_hideEl = CS_hideEl;
+_showEl = CS_showEl;
+_showInstead = CS_showInstead;
+_toggleHidden = CS_toggleHidden;
+_toggleCollapse = CS_toggleCollapse;
+_addClickListener = CS_addClickListener;
diff --git a/static/js/framework/framework-menu.js b/static/js/framework/framework-menu.js
new file mode 100644
index 0000000..35bbebc
--- /dev/null
+++ b/static/js/framework/framework-menu.js
@@ -0,0 +1,566 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview This file represents a standalone, reusable drop down menu
+ * widget that can be attached to any element on a given page. It supports
+ * multiple instances of the widget on a page. It has no dependencies. Usage
+ * is as simple as creating a new Menu object and supplying it with a target
+ * element.
+ */
+
+/**
+ * The entry point and constructor for the Menu object. Creating
+ * a valid instance of this object will insert a drop down menu
+ * near the element supplied as the target, attach all the necessary
+ * events and insert the necessary elements on the page.
+ *
+ * @param {Element} target the target element on the page to which
+ * the drop down menu will be placed near.
+ * @param {Function=} opt_onShow function to execute every time the
+ * menu is made visible, most likely through a click on the target.
+ * @constructor
+ */
+var Menu = function(target, opt_onShow) {
+ this.iid = Menu.instance.length;
+ Menu.instance[this.iid] = this;
+ this.target = target;
+ this.onShow = opt_onShow || null;
+
+ // An optional trigger element on the page that can be used to trigger
+ // the drop-down. Currently hard-coded to be the same as the target element.
+ this.trigger = target;
+ this.items = [];
+ this.onOpenEvents = [];
+ this.menu = this.createElement('div', 'menuDiv instance' + this.iid);
+ this.targetId = this.target.getAttribute('id');
+ let menuId = (this.targetId != null) ?
+ 'menuDiv-' + this.targetId : 'menuDiv-instance' + this.iid;
+ this.menu.setAttribute('id', menuId);
+ this.menu.role = 'listbox';
+ this.hide();
+ this.addCategory('default');
+ this.addEvent(this.trigger, 'click', this.toggle.bind(this));
+ this.addEvent(window, 'resize', this.adjustSizeAndLocation.bind(this));
+
+ // Hide the menu if a user clicks outside the menu widget
+ this.addEvent(document, 'click', this.hide.bind(this));
+ this.addEvent(this.menu, 'click', this.stopPropagation());
+ this.addEvent(this.trigger, 'click', this.stopPropagation());
+};
+
+// A reference to the element or node that the drop down
+// will appear next to
+Menu.prototype.target = null;
+
+// Element ID of the target. ID will be assigned to the newly created
+// menu div based on the target ID. A default ID will be
+// assigned If there is no ID on the target.
+Menu.prototype.targetId = null;
+
+/**
+ * A reference to the element or node that will trigger
+ * the drop down to appear. If not specified, this value
+ * will be the same as <Menu Instance>.target
+ * @type {Element}
+ */
+Menu.prototype.trigger = null;
+
+// A reference to the event type that will "open" the
+// menu div. By default this is the (on)click method.
+Menu.prototype.triggerType = null;
+
+// A reference to the element that will appear when the
+// trigger is clicked.
+Menu.prototype.menu = null;
+
+/**
+ * Function to execute every time the menu is made shown.
+ * @type {Function}
+ */
+Menu.prototype.onShow = null;
+
+// A list of category divs. By default these categories
+// are set to display none until at least one element
+// is placed within them.
+Menu.prototype.categories = null;
+
+// An id used to track timed intervals
+Menu.prototype.thread = -1;
+
+// The static instance id (iid) denoting which menu in the
+// list of Menu.instance items is this instantiated object.
+Menu.prototype.iid = -1;
+
+// A counter to indicate the number of items added with
+// addItem(). After 5 items, a height is set on the menu
+// and a scroll bar will appear.
+Menu.prototype.items = null;
+
+// A flag to detect whether or not a scroll bar has been added
+Menu.prototype.scrolls = false;
+
+// onOpen event handlers; each function in this list will
+// be executed and passed the executing instance as a
+// parameter before the menu is to be displayed.
+Menu.prototype.onOpenEvents = null;
+
+/**
+ * An extended short-cut for document.createElement(); this
+ * method allows the creation of an element, the assignment
+ * of one or more class names and the ability to set the
+ * content of the created element all with one function call.
+ * @param {string} element name of the element to create. Examples would
+ * be 'div' or 'a'.
+ * @param {string} opt_className an optional string to assign to the
+ * newly created element's className property.
+ * @param {string|Element} opt_content either a snippet of HTML or a HTML
+ * element that is to be appended to the newly created element.
+ * @return {Element} a reference to the newly created element.
+ */
+Menu.prototype.createElement = function(element, opt_className, opt_content) {
+ let div = document.createElement(element);
+ div.className = opt_className;
+ if (opt_content) {
+ this.append(opt_content, div);
+ }
+ return div;
+};
+
+/**
+ * Uses a fairly browser agnostic approach to applying a callback to
+ * an element on the page.
+ *
+ * @param {Element|EventTarget} element a reference to an element on the page to
+ * which to attach and event.
+ * @param {string} eventType a browser compatible event type as a string
+ * without the sometimes assumed on- prefix. Examples: 'click',
+ * 'mousedown', 'mouseover', etc...
+ * @param {Function} callback a function reference to invoke when the
+ * the event occurs.
+ */
+Menu.prototype.addEvent = function(element, eventType, callback) {
+ if (element.addEventListener) {
+ element.addEventListener(eventType, callback, false);
+ } else {
+ try {
+ element.attachEvent('on' + eventType, callback);
+ } catch (e) {
+ element['on' + eventType] = callback;
+ }
+ }
+};
+
+/**
+ * Similar to addEvent, this provides a specialied handler for onOpen
+ * events that apply to this instance of the Menu class. The supplied
+ * callbacks are appended to an internal array and called in order
+ * every time the menu is opened. The array can be accessed via
+ * menuInstance.onOpenEvents.
+ */
+Menu.prototype.addOnOpen = function(eventCallback) {
+ let eventIndex = this.onOpenEvents.length;
+ this.onOpenEvents.push(eventCallback);
+ return eventIndex;
+};
+
+/**
+ * This method will create a div with the classes .menuCategory and the
+ * name of the category as supplied in the first parameter. It then, if
+ * a title is supplied, creates a title div and appends it as well. The
+ * optional title is styled with the .categoryTitle and category name
+ * class.
+ *
+ * Categories are stored within the menu object instance for programmatic
+ * manipulation in the array, menuInstance.categories. Note also that this
+ * array is doubly linked insofar as that the category div can be accessed
+ * via it's index in the array as well as by instance.categories[category]
+ * where category is the string name supplied when creating the category.
+ *
+ * @param {string} category the string name used to create the category;
+ * used as both a class name and a key into the internal array. It
+ * must be a valid JavaScript variable name.
+ * @param {string|Element} opt_title this optional field is used to visibly
+ * denote the category title. It can be either HTML or an element.
+ * @return {Element} the newly created div.
+ */
+Menu.prototype.addCategory = function(category, opt_title) {
+ this.categories = this.categories || [];
+ let categoryDiv = this.createElement('div', 'menuCategory ' + category);
+ categoryDiv._categoryName = category;
+ if (opt_title) {
+ let categoryTitle = this.createElement('b', 'categoryTitle ' +
+ category, opt_title);
+ categoryTitle.style.display = 'block';
+ this.append(categoryTitle);
+ categoryDiv._categoryTitle = categoryTitle;
+ }
+ this.append(categoryDiv);
+ this.categories[this.categories.length] = this.categories[category] =
+ categoryDiv;
+
+ return categoryDiv;
+};
+
+/**
+ * This method removes the contents of a given category but does not
+ * remove the category itself.
+ */
+Menu.prototype.emptyCategory = function(category) {
+ if (!this.categories[category]) {
+ return;
+ }
+ let div = this.categories[category];
+ for (let i = div.childNodes.length - 1; i >= 0; i--) {
+ div.removeChild(div.childNodes[i]);
+ }
+};
+
+/**
+ * This function is the most drastic of the cleansing functions; it removes
+ * all categories and all menu items and all HTML snippets that have been
+ * added to this instance of the Menu class.
+ */
+Menu.prototype.clear = function() {
+ for (var i = 0; i < this.categories.length; i++) {
+ // Prevent memory leaks
+ this.categories[this.categories[i]._categoryName] = null;
+ }
+ this.items.splice(0, this.items.length);
+ this.categories.splice(0, this.categories.length);
+ this.categories = [];
+ this.items = [];
+ for (var i = this.menu.childNodes.length - 1; i >= 0; i--) {
+ this.menu.removeChild(this.menu.childNodes[i]);
+ }
+};
+
+/**
+ * Passed an instance of a menu item, it will be removed from the menu
+ * object, including any residual array links and possible memory leaks.
+ * @param {Element} item a reference to the menu item to remove.
+ * @return {Element} returns the item removed.
+ */
+Menu.prototype.removeItem = function(item) {
+ let result = null;
+ for (let i = 0; i < this.items.length; i++) {
+ if (this.items[i] == item) {
+ result = this.items[i];
+ this.items.splice(i, 1);
+ }
+ // Renumber
+ this.items[i].item._index = i;
+ }
+ return result;
+};
+
+/**
+ * Removes a category from the menu element and all of its children thus
+ * allowing the Element to be collected by the browsers VM.
+ * @param {string} category the name of the category to retrieve and remove.
+ */
+Menu.prototype.removeCategory = function(category) {
+ let div = this.categories[category];
+ if (!div || !div.parentNode) {
+ return;
+ }
+ if (div._categoryTitle) {
+ div._categoryTitle.parentNode.removeChild(div._categoryTitle);
+ }
+ div.parentNode.removeChild(div);
+ for (var i = 0; i < this.categories.length; i++) {
+ if (this.categories[i] === div) {
+ this.categories[this.categories[i]._categoryName] = null;
+ this.categories.splice(i, 1);
+ return;
+ }
+ }
+ for (var i = 0; i < div.childNodes.length; i++) {
+ if (div.childNodes[i]._index) {
+ this.items.splice(div.childNodes[i]._index, 1);
+ } else {
+ this.removeItem(div.childNodes[i]);
+ }
+ }
+};
+
+/**
+ * This heart of the menu population scheme, the addItem function creates
+ * a combination of elements that visually form up a menu item. If no
+ * category is supplied, the default category is used. The menu item is
+ * an <a> tag with the class .menuItem. The menu item is directly styled
+ * as a block element. Other than that, all styling should be done via a
+ * external CSS definition.
+ *
+ * @param {string|Element} html_or_element a string of HTML text or a
+ * HTML element denoting the contents of the menu item.
+ * @param {string} opt_href the href of the menu item link. This is
+ * the most direct way of defining the menu items function.
+ * [Default: '#'].
+ * @param {string} opt_category the category string name of the category
+ * to append the menu item to. If the category doesn't exist, one will
+ * be created. [Default: 'default'].
+ * @param {string} opt_title used when creating a new category and is
+ * otherwise ignored completely. It is also ignored when supplied if
+ * the named category already exists.
+ * @return {Element} returns the element that was created.
+ */
+Menu.prototype.addItem = function(html_or_element, opt_href, opt_category,
+ opt_title) {
+ let category = opt_category ? (this.categories[opt_category] ||
+ this.addCategory(opt_category, opt_title)) :
+ this.categories['default'];
+ let menuHref = (opt_href == undefined ? '#' : opt_href);
+ let menuItem = undefined;
+ if (menuHref) {
+ menuItem = this.createElement('a', 'menuItem', html_or_element);
+ } else {
+ menuItem = this.createElement('span', 'menuText', html_or_element);
+ }
+ let itemText = typeof html_or_element == 'string' ? html_or_element :
+ html_or_element.textContent || 'ERROR';
+
+ menuItem.style.display = 'block';
+ if (menuHref) {
+ menuItem.setAttribute('href', menuHref);
+ }
+ menuItem._index = this.items.length;
+ menuItem.role = 'option';
+ this.append(menuItem, category);
+ this.items[this.items.length] = {item: menuItem, text: itemText};
+
+ return menuItem;
+};
+
+/**
+ * Adds a visual HTML separator to the menu, optionally creating a
+ * category as per addItem(). See above.
+ * @param {string} opt_category the category string name of the category
+ * to append the menu item to. If the category doesn't exist, one will
+ * be created. [Default: 'default'].
+ * @param {string} opt_title used when creating a new category and is
+ * otherwise ignored completely. It is also ignored when supplied if
+ * the named category already exists.
+ */
+Menu.prototype.addSeparator = function(opt_category, opt_title) {
+ let category = opt_category ? (this.categories[opt_category] ||
+ this.addCategory(opt_category, opt_title)) :
+ this.categories['default'];
+ let hr = this.createElement('hr', 'menuSeparator');
+ this.append(hr, category);
+};
+
+/**
+ * This method performs all the dirty work of positioning the menu. It is
+ * responsible for dynamic sizing, insertion and deletion of scroll bars
+ * and calculation of offscreen width considerations.
+ */
+Menu.prototype.adjustSizeAndLocation = function() {
+ let style = this.menu.style;
+ style.position = 'absolute';
+
+ let firstCategory = null;
+ for (let i = 0; i < this.categories.length; i++) {
+ this.categories[i].className = this.categories[i].className.
+ replace(/ first/, '');
+ if (this.categories[i].childNodes.length == 0) {
+ this.categories[i].style.display = 'none';
+ } else {
+ this.categories[i].style.display = '';
+ if (!firstCategory) {
+ firstCategory = this.categories[i];
+ firstCategory.className += ' first';
+ }
+ }
+ }
+
+ let alreadyVisible = style.display != 'none' &&
+ style.visibility != 'hidden';
+ let docElemWidth = document.documentElement.clientWidth;
+ let docElemHeight = document.documentElement.clientHeight;
+ let pageSize = {
+ w: (window.innerWidth || docElemWidth && docElemWidth > 0 ?
+ docElemWidth : document.body.clientWidth) || 1,
+ h: (window.innerHeight || docElemHeight && docElemHeight > 0 ?
+ docElemHeight : document.body.clientHeight) || 1,
+ };
+ let targetPos = this.find(this.target);
+ let targetSize = {w: this.target.offsetWidth,
+ h: this.target.offsetHeight};
+ let menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
+
+ if (!alreadyVisible) {
+ let oldVisibility = style.visibility;
+ let oldDisplay = style.display;
+ style.visibility = 'hidden';
+ style.display = '';
+ style.height = '';
+ style.width = '';
+ menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
+ style.display = oldDisplay;
+ style.visibility = oldVisibility;
+ }
+
+ let addScroll = (this.menu.offsetHeight / pageSize.h) > 0.8;
+ if (addScroll) {
+ menuSize.h = parseInt((pageSize.h * 0.8), 10);
+ style.height = menuSize.h + 'px';
+ style.overflowX = 'hidden';
+ style.overflowY = 'auto';
+ } else {
+ style.height = style.overflowY = style.overflowX = '';
+ }
+
+ style.top = (targetPos.y + targetSize.h) + 'px';
+ style.left = targetPos.x + 'px';
+
+ if (menuSize.w < 175) {
+ style.width = '175px';
+ }
+
+ if (addScroll) {
+ style.width = parseInt(style.width, 10) + 13 + 'px';
+ }
+
+ if ((targetPos.x + menuSize.w) > pageSize.w) {
+ style.left = targetPos.x - (menuSize.w - targetSize.w) + 'px';
+ }
+};
+
+
+/**
+ * This function is used heavily, internally. It appends text
+ * or the supplied element via appendChild(). If
+ * the opt_target variable is present, the supplied element will be
+ * the container rather than the menu div for this instance.
+ *
+ * @param {string|Element} text_or_element the html or element to insert
+ * into opt_target.
+ * @param {Element} opt_target the target element it should be appended to.
+ *
+ */
+Menu.prototype.append = function(text_or_element, opt_target) {
+ let element = opt_target || this.menu;
+ if (typeof opt_target == 'string' && this.categories[opt_target]) {
+ element = this.categories[opt_target];
+ }
+ if (typeof text_or_element == 'string') {
+ element.textContent += text_or_element;
+ } else {
+ element.appendChild(text_or_element);
+ }
+};
+
+/**
+ * Displays the menu (such as upon mouseover).
+ */
+Menu.prototype.over = function() {
+ if (this.menu.style.display != 'none') {
+ this.show();
+ }
+ if (this.thread != -1) {
+ clearTimeout(this.thread);
+ this.thread = -1;
+ }
+};
+
+/**
+ * Hides the menu (such as upon mouseout).
+ */
+Menu.prototype.out = function() {
+ if (this.thread != -1) {
+ clearTimeout(this.thread);
+ this.thread = -1;
+ }
+ this.thread = setTimeout(this.hide.bind(this), 400);
+};
+
+/**
+ * Stops event propagation.
+ */
+Menu.prototype.stopPropagation = function() {
+ return (function(e) {
+ if (!e) {
+ e = window.event;
+ }
+ e.cancelBubble = true;
+ if (e.stopPropagation) {
+ e.stopPropagation();
+ }
+ });
+};
+
+/**
+ * Toggles the menu between hide/show.
+ */
+Menu.prototype.toggle = function(event) {
+ event.preventDefault();
+ if (this.menu.style.display == 'none') {
+ this.show();
+ } else {
+ this.hide();
+ }
+};
+
+/**
+ * Makes the menu visible, then calls the user-supplied onShow callback.
+ */
+Menu.prototype.show = function() {
+ if (this.menu.style.display != '') {
+ for (var i = 0; i < this.onOpenEvents.length; i++) {
+ this.onOpenEvents[i].call(null, this);
+ }
+
+ // Invisibly show it first
+ this.menu.style.visibility = 'hidden';
+ this.menu.style.display = '';
+ this.adjustSizeAndLocation();
+ if (this.trigger.nodeName && this.trigger.nodeName == 'A') {
+ this.trigger.blur();
+ }
+ this.menu.style.visibility = 'visible';
+
+ // Hide other menus
+ for (var i = 0; i < Menu.instance.length; i++) {
+ let menuInstance = Menu.instance[i];
+ if (menuInstance != this) {
+ menuInstance.hide();
+ }
+ }
+
+ if (this.onShow) {
+ this.onShow();
+ }
+ }
+};
+
+/**
+ * Makes the menu invisible.
+ */
+Menu.prototype.hide = function() {
+ this.menu.style.display = 'none';
+};
+
+Menu.prototype.find = function(element) {
+ let curleft = 0, curtop = 0;
+ if (element.offsetParent) {
+ do {
+ curleft += element.offsetLeft;
+ curtop += element.offsetTop;
+ }
+ while ((element = element.offsetParent) && (element.style &&
+ element.style.position != 'relative' &&
+ element.style.position != 'absolute'));
+ }
+ return {x: curleft, y: curtop};
+};
+
+/**
+ * A static array of object instances for global reference.
+ * @type {Array.<Menu>}
+ */
+Menu.instance = [];
diff --git a/static/js/framework/framework-myhotlists.js b/static/js/framework/framework-myhotlists.js
new file mode 100644
index 0000000..6459090
--- /dev/null
+++ b/static/js/framework/framework-myhotlists.js
@@ -0,0 +1,109 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview This file initializes the "My Hotlists" drop down menu in the
+ * user bar. It utilizes the menu widget defined in framework-menu.js.
+ */
+
+/** @type {Menu} */
+var myhotlists;
+
+(function() {
+ var target = document.getElementById('hotlists-dropdown');
+
+ if (!target) {
+ return;
+ }
+
+ myhotlists = new Menu(target, function() {});
+
+ myhotlists.addEvent(window, 'load', CS_updateHotlists);
+ myhotlists.addOnOpen(CS_updateHotlists);
+ myhotlists.addEvent(window, 'load', function() {
+ document.body.appendChild(myhotlists.menu);
+ });
+})();
+
+
+/**
+ * Grabs the list of logged in user's hotlists to populate the "My Hotlists"
+ * drop down menu.
+ */
+async function CS_updateHotlists() {
+ if (!myhotlists) return;
+
+ if (!window.CS_env.loggedInUserEmail) {
+ myhotlists.clear();
+ myhotlists.addItem('sign in to see your hotlists',
+ window.CS_env.login_url,
+ 'controls');
+ return;
+ }
+
+ const ownedHotlistsMessage = {
+ user: {
+ display_name: window.CS_env.loggedInUserEmail,
+ }};
+
+ const responses = await Promise.all([
+ window.prpcClient.call(
+ 'monorail.Features', 'ListHotlistsByUser', ownedHotlistsMessage),
+ window.prpcClient.call(
+ 'monorail.Features', 'ListStarredHotlists', {}),
+ window.prpcClient.call(
+ 'monorail.Features', 'ListRecentlyVisitedHotlists', {}),
+ ]);
+ const ownedHotlists = responses[0];
+ const starredHotlists = responses[1];
+ const visitedHotlists = responses[2];
+
+ myhotlists.clear();
+
+ const sortByName = (hotlist1, hotlist2) => {
+ hotlist1.name.localeCompare(hotlist2.name);
+ };
+
+ if (ownedHotlists.hotlists) {
+ ownedHotlists.hotlists.sort(sortByName);
+ ownedHotlists.hotlists.forEach(hotlist => {
+ const name = hotlist.name;
+ const userId = hotlist.ownerRef.userId;
+ const url = `/u/${userId}/hotlists/${name}`;
+ myhotlists.addItem(name, url, 'hotlists', 'Hotlists');
+ });
+ }
+
+ if (starredHotlists.hotlists) {
+ myhotlists.addSeparator();
+ starredHotlists.hotlists.sort(sortByName);
+ starredHotlists.hotlists.forEach(hotlist => {
+ const name = hotlist.name;
+ const userId = hotlist.ownerRef.userId;
+ const url = `/u/${userId}/hotlists/${name}`;
+ myhotlists.addItem(name, url, 'starred_hotlists', 'Starred Hotlists');
+ });
+ }
+
+ if (visitedHotlists.hotlists) {
+ myhotlists.addSeparator();
+ visitedHotlists.hotlists.sort(sortByName);
+ visitedHotlists.hotlists.forEach(hotlist => {
+ const name = hotlist.name;
+ const userId = hotlist.ownerRef.userId;
+ const url = `/u/${userId}/hotlists/${name}`;
+ myhotlists.addItem(
+ name, url, 'visited_hotlists', 'Recently Visited Hotlists');
+ });
+ }
+
+ myhotlists.addSeparator();
+ myhotlists.addItem(
+ 'All hotlists', `/u/${window.CS_env.loggedInUserEmail}/hotlists`,
+ 'controls');
+ myhotlists.addItem('Create hotlist', '/hosting/createHotlist', 'controls');
+}
diff --git a/static/js/framework/framework-stars.js b/static/js/framework/framework-stars.js
new file mode 100644
index 0000000..946264e
--- /dev/null
+++ b/static/js/framework/framework-stars.js
@@ -0,0 +1,114 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that support setting and showing
+ * stars throughout Monorail.
+ */
+
+
+/**
+ * The character to display when the user has starred an issue.
+ */
+var TKR_STAR_ON = '\u2605';
+
+
+/**
+ * The character to display when the user has not starred an issue.
+ */
+var TKR_STAR_OFF = '\u2606';
+
+
+/**
+ * Function to toggle the star on an issue. Does both an update of the
+ * DOM and hit the server to record the star.
+ *
+ * @param {Element} el The star <a> element.
+ * @param {String} projectName name of the project to be starred, or name of
+ * the project containing the issue to be starred.
+ * @param {Integer} localId number of the issue to be starred.
+ * @param {String} projectName number of the user to be starred.
+ */
+function TKR_toggleStar(el, projectName, localId, userId, hotlistId) {
+ const starred = (el.textContent.trim() == TKR_STAR_OFF);
+ TKR_toggleStarLocal(el);
+
+ const starRequestMessage = {starred: Boolean(starred)};
+ if (userId) {
+ starRequestMessage.user_ref = {user_id: userId};
+ window.prpcClient.call('monorail.Users', 'StarUser', starRequestMessage);
+ } else if (projectName && localId) {
+ starRequestMessage.issue_ref = {
+ project_name: projectName,
+ local_id: localId,
+ };
+ window.prpcClient.call('monorail.Issues', 'StarIssue', starRequestMessage);
+ } else if (projectName) {
+ starRequestMessage.project_name = projectName;
+ window.prpcClient.call(
+ 'monorail.Projects', 'StarProject', starRequestMessage);
+ } else if (hotlistId) {
+ starRequestMessage.hotlist_ref = {hotlist_id: hotlistId};
+ window.prpcClient.call(
+ 'monorail.Features', 'StarHotlist', starRequestMessage);
+ }
+}
+
+
+/**
+ * Just update the display state of a star, without contacting the server.
+ * Optionally update the value of a form element as well. Useful for when
+ * a user is entering a new issue and wants to set its initial starred state.
+ * @param {Element} el Star <img> element.
+ * @param {string} opt_formElementId HTML ID of the hidden form element for
+ * stars.
+ */
+function TKR_toggleStarLocal(el, opt_formElementId) {
+ let starred = (el.textContent.trim() == TKR_STAR_OFF) ? 1 : 0;
+
+ el.textContent = starred ? TKR_STAR_ON : TKR_STAR_OFF;
+ el.style.color = starred ? 'cornflowerblue' : 'grey';
+ el.title = starred ? 'You have starred this item' : 'Click to star this item';
+
+ if (opt_formElementId) {
+ $(opt_formElementId).value = '' + starred; // convert to string
+ }
+}
+
+
+/**
+ * When we show two star icons on the same details page, keep them
+ * in sync with each other. And, update a message about starring
+ * that is displayed near the issue update form.
+ * @param {Element} clickedStar The star that the user clicked on.
+ * @param {string} otherStarId ID of the other star icon.
+ */
+function TKR_syncStarIcons(clickedStar, otherStarId) {
+ let otherStar = document.getElementById(otherStarId);
+ if (!otherStar) {
+ return;
+ }
+ TKR_toggleStarLocal(otherStar);
+
+ let vote_feedback = document.getElementById('vote_feedback');
+ if (!vote_feedback) {
+ return;
+ }
+
+ if (clickedStar.textContent == TKR_STAR_OFF) {
+ vote_feedback.textContent =
+ 'Vote for this issue and get email change notifications.';
+ } else {
+ vote_feedback.textContent = 'Your vote has been recorded.';
+ }
+}
+
+
+// Exports
+_TKR_toggleStar = TKR_toggleStar;
+_TKR_toggleStarLocal = TKR_toggleStarLocal;
+_TKR_syncStarIcons = TKR_syncStarIcons;
diff --git a/static/js/framework/project-name-check.js b/static/js/framework/project-name-check.js
new file mode 100644
index 0000000..65c2bdf
--- /dev/null
+++ b/static/js/framework/project-name-check.js
@@ -0,0 +1,30 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Functions that support project name checks when
+ * creating a new project.
+ */
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName The proposed project name.
+ */
+async function checkProjectName(projectName) {
+ const message = {
+ project_name: projectName
+ };
+ const response = await window.prpcClient.call(
+ 'monorail.Projects', 'CheckProjectName', message);
+ if (response.error) {
+ $('projectnamefeedback').textContent = response.error;
+ $('submit_btn').disabled = 'disabled';
+ }
+}
+
+// Make this function globally available
+_CP_checkProjectName = checkProjectName;
diff --git a/static/js/graveyard/common.js b/static/js/graveyard/common.js
new file mode 100644
index 0000000..621a626
--- /dev/null
+++ b/static/js/graveyard/common.js
@@ -0,0 +1,709 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// ------------------------------------------------------------------------
+// This file contains common utilities and basic javascript infrastructure.
+//
+// Notes:
+// * Press 'D' to toggle debug mode.
+//
+// Functions:
+//
+// - Assertions
+// DEPRECATED: Use assert.js
+// AssertTrue(): assert an expression. Throws an exception if false.
+// Fail(): Throws an exception. (Mark block of code that should be unreachable)
+// AssertEquals(): assert that two values are equal.
+// AssertType(): assert that a value has a particular type
+//
+// - Cookies
+// SetCookie(): Sets a cookie.
+// ExpireCookie(): Expires a cookie.
+// GetCookie(): Gets a cookie value.
+//
+// - Dynamic HTML/DOM utilities
+// MaybeGetElement(): get an element by its id
+// GetElement(): get an element by its id
+// GetParentNode(): Get the parent of an element
+// GetAttribute(): Get attribute value of a DOM node
+// GetInnerHTML(): get the inner HTML of a node
+// SetCssStyle(): Sets a CSS property of a node.
+// GetStyleProperty(): Get CSS property from a style attribute string
+// GetCellIndex(): Get the index of a table cell in a table row
+// ShowElement(): Show/hide element by setting the "display" css property.
+// ShowBlockElement(): Show/hide block element
+// SetButtonText(): Set the text of a button element.
+// AppendNewElement(): Create and append a html element to a parent node.
+// CreateDIV(): Create a DIV element and append to the document.
+// HasClass(): check if element has a given class
+// AddClass(): add a class to an element
+// RemoveClass(): remove a class from an element
+//
+// - Window/Screen utiltiies
+// GetPageOffsetLeft(): get the X page offset of an element
+// GetPageOffsetTop(): get the Y page offset of an element
+// GetPageOffset(): get the X and Y page offsets of an element
+// GetPageOffsetRight() : get X page offset of the right side of an element
+// GetPageOffsetRight() : get Y page offset of the bottom of an element
+// GetScrollTop(): get the vertical scrolling pos of a window.
+// GetScrollLeft(): get the horizontal scrolling pos of a window
+// IsScrollAtEnd(): check if window scrollbar has reached its maximum offset
+// ScrollTo(): scroll window to a position
+// ScrollIntoView(): scroll window so that an element is in view.
+// GetWindowWidth(): get width of a window.
+// GetWindowHeight(): get height of a window
+// GetAvailScreenWidth(): get available screen width
+// GetAvailScreenHeight(): get available screen height
+// GetNiceWindowHeight(): get a nice height for a new browser window.
+// Open{External/Internal}Window(): open a separate window
+// CloseWindow(): close a window
+//
+// - DOM walking utilities
+// AnnotateTerms(): find terms in a node and decorate them with some tag
+// AnnotateText(): find terms in a text node and decorate them with some tag
+//
+// - String utilties
+// HtmlEscape(): html escapes a string
+// HtmlUnescape(): remove html-escaping.
+// QuoteEscape(): escape " quotes.
+// CollapseWhitespace(): collapse multiple whitespace into one whitespace.
+// Trim(): trim whitespace on ends of string
+// IsEmpty(): check if CollapseWhiteSpace(String) == ""
+// IsLetterOrDigit(): check if a character is a letter or a digit
+// ConvertEOLToLF(): normalize the new-lines of a string.
+// HtmlEscapeInsertWbrs(): HtmlEscapes and inserts <wbr>s (word break tags)
+// after every n non-space chars and/or after or before certain special chars
+//
+// - TextArea utilities
+// GetCursorPos(): finds the cursor position of a textfield
+// SetCursorPos(): sets the cursor position in a textfield
+//
+// - Array utilities
+// FindInArray(): do a linear search to find an element value.
+// DeleteArrayElement(): return a new array with a specific value removed.
+// CloneObject(): clone an object, copying its values recursively.
+// CloneEvent(): clone an event; cannot use CloneObject because it
+// suffers from infinite recursion
+//
+// - Formatting utilities
+// PrintArray(): used to print/generate HTML by combining static text
+// and dynamic strings.
+// ImageHtml(): create html for an img tag
+// FormatJSLink(): formats a link that invokes js code when clicked.
+// MakeId3(): formats an id that has two id numbers, eg, foo_3_7
+//
+// - Timeouts
+// SafeTimeout(): sets a timeout with protection against ugly JS-errors
+// CancelTimeout(): cancels a timeout with a given ID
+// CancelAllTimeouts(): cancels all timeouts on a given window
+//
+// - Miscellaneous
+// IsDefined(): returns true if argument is not undefined
+// ------------------------------------------------------------------------
+
+// browser detection
+function BR_AgentContains_(str) {
+ if (str in BR_AgentContains_cache_) {
+ return BR_AgentContains_cache_[str];
+ }
+
+ return BR_AgentContains_cache_[str] =
+ (navigator.userAgent.toLowerCase().indexOf(str) != -1);
+}
+// We cache the results of the indexOf operation. This gets us a 10x benefit in
+// Gecko, 8x in Safari and 4x in MSIE for all of the browser checks
+var BR_AgentContains_cache_ = {};
+
+function BR_IsIE() {
+ return (BR_AgentContains_('msie') || BR_AgentContains_('trident')) &&
+ !window.opera;
+}
+
+function BR_IsKonqueror() {
+ return BR_AgentContains_('konqueror');
+}
+
+function BR_IsSafari() {
+ return BR_AgentContains_('safari') || BR_IsKonqueror();
+}
+
+function BR_IsNav() {
+ return !BR_IsIE() &&
+ !BR_IsSafari() &&
+ BR_AgentContains_('mozilla');
+}
+
+var BACKSPACE_KEYNAME = 'Backspace';
+var COMMA_KEYNAME = ',';
+var DELETE_KEYNAME = 'Delete';
+var UP_KEYNAME = 'ArrowUp';
+var DOWN_KEYNAME = 'ArrowDown';
+var LEFT_KEYNAME = 'ArrowLeft';
+var RIGHT_KEYNAME = 'ArrowRight';
+var ENTER_KEYNAME = 'Enter';
+var ESC_KEYNAME = 'Escape';
+var SPACE_KEYNAME = ' ';
+var TAB_KEYNAME = 'Tab';
+var SHIFT_KEYNAME = 'Shift';
+var PAGE_DOWN_KEYNAME = 'PageDown';
+var PAGE_UP_KEYNAME = 'PageUp';
+
+var MAX_EMAIL_ADDRESS_LENGTH = 320; // 64 + '@' + 255
+var MAX_SIGNATURE_LENGTH = 1000; // 1000 chars of maximum signature
+
+// ------------------------------------------------------------------------
+// Assertions
+// DEPRECATED: Use assert.js
+// ------------------------------------------------------------------------
+/**
+ * DEPRECATED: Use assert.js
+ */
+function raise(msg) {
+ if (typeof Error != 'undefined') {
+ throw new Error(msg || 'Assertion Failed');
+ } else {
+ throw (msg);
+ }
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Fail() is useful for marking logic paths that should
+ * not be reached. For example, if you have a class that uses
+ * ints for enums:
+ *
+ * MyClass.ENUM_FOO = 1;
+ * MyClass.ENUM_BAR = 2;
+ * MyClass.ENUM_BAZ = 3;
+ *
+ * And a switch statement elsewhere in your code that
+ * has cases for each of these enums, then you can
+ * "protect" your code as follows:
+ *
+ * switch(type) {
+ * case MyClass.ENUM_FOO: doFooThing(); break;
+ * case MyClass.ENUM_BAR: doBarThing(); break;
+ * case MyClass.ENUM_BAZ: doBazThing(); break;
+ * default:
+ * Fail("No enum in MyClass with value: " + type);
+ * }
+ *
+ * This way, if someone introduces a new value for this enum
+ * without noticing this switch statement, then the code will
+ * fail if the logic allows it to reach the switch with the
+ * new value, alerting the developer that they should add a
+ * case to the switch to handle the new value they have introduced.
+ *
+ * @param {string} opt_msg to display for failure
+ * DEFAULT: "Assertion failed"
+ */
+function Fail(opt_msg) {
+ opt_msg = opt_msg || 'Assertion failed';
+ if (IsDefined(DumpError)) DumpError(opt_msg + '\n');
+ raise(opt_msg);
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Asserts that an expression is true (non-zero and non-null).
+ *
+ * Note that it is critical not to pass logic
+ * with side-effects as the expression for AssertTrue
+ * because if the assertions are removed by the
+ * JSCompiler, then the expression will be removed
+ * as well, in which case the side-effects will
+ * be lost. So instead of this:
+ *
+ * AssertTrue( criticalComputation() );
+ *
+ * Do this:
+ *
+ * var result = criticalComputation();
+ * AssertTrue(result);
+ *
+ * @param expression to evaluate
+ * @param {string} opt_msg to display if the assertion fails
+ *
+ */
+function AssertTrue(expression, opt_msg) {
+ if (!expression) {
+ opt_msg = opt_msg || 'Assertion failed';
+ Fail(opt_msg);
+ }
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Asserts that a value is of the provided type.
+ *
+ * AssertType(6, Number);
+ * AssertType("ijk", String);
+ * AssertType([], Array);
+ * AssertType({}, Object);
+ * AssertType(ICAL_Date.now(), ICAL_Date);
+ *
+ * @param value
+ * @param type A constructor function
+ * @param {string} opt_msg to display if the assertion fails
+ */
+function AssertType(value, type, opt_msg) {
+ // for backwards compatability only
+ if (typeof value == type) return;
+
+ if (value || value == '') {
+ try {
+ if (type == AssertTypeMap[typeof value] || value instanceof type) return;
+ } catch (e) {/* failure, type was an illegal argument to instanceof */}
+ }
+ let makeMsg = opt_msg === undefined;
+ if (makeMsg) {
+ if (typeof type == 'function') {
+ let match = type.toString().match(/^\s*function\s+([^\s\{]+)/);
+ if (match) type = match[1];
+ }
+ opt_msg = 'AssertType failed: <' + value + '> not typeof '+ type;
+ }
+ Fail(opt_msg);
+}
+
+var AssertTypeMap = {
+ 'string': String,
+ 'number': Number,
+ 'boolean': Boolean,
+};
+
+var EXPIRED_COOKIE_VALUE = 'EXPIRED';
+
+
+// ------------------------------------------------------------------------
+// Window/screen utilities
+// TODO: these should be renamed (e.g. GetWindowWidth to GetWindowInnerWidth
+// and moved to geom.js)
+// ------------------------------------------------------------------------
+// Get page offset of an element
+function GetPageOffsetLeft(el) {
+ let x = el.offsetLeft;
+ if (el.offsetParent != null) {
+ x += GetPageOffsetLeft(el.offsetParent);
+ }
+ return x;
+}
+
+// Get page offset of an element
+function GetPageOffsetTop(el) {
+ let y = el.offsetTop;
+ if (el.offsetParent != null) {
+ y += GetPageOffsetTop(el.offsetParent);
+ }
+ return y;
+}
+
+// Get page offset of an element
+function GetPageOffset(el) {
+ let x = el.offsetLeft;
+ let y = el.offsetTop;
+ if (el.offsetParent != null) {
+ let pos = GetPageOffset(el.offsetParent);
+ x += pos.x;
+ y += pos.y;
+ }
+ return {x: x, y: y};
+}
+
+// Get the y position scroll offset.
+function GetScrollTop(win) {
+ return GetWindowPropertyByBrowser_(win, getScrollTopGetters_);
+}
+
+var getScrollTopGetters_ = {
+ ieQuirks_: function(win) {
+ return win.document.body.scrollTop;
+ },
+ ieStandards_: function(win) {
+ return win.document.documentElement.scrollTop;
+ },
+ dom_: function(win) {
+ return win.pageYOffset;
+ },
+};
+
+// Get the x position scroll offset.
+function GetScrollLeft(win) {
+ return GetWindowPropertyByBrowser_(win, getScrollLeftGetters_);
+}
+
+var getScrollLeftGetters_ = {
+ ieQuirks_: function(win) {
+ return win.document.body.scrollLeft;
+ },
+ ieStandards_: function(win) {
+ return win.document.documentElement.scrollLeft;
+ },
+ dom_: function(win) {
+ return win.pageXOffset;
+ },
+};
+
+// Scroll so that as far as possible the entire element is in view.
+var ALIGN_BOTTOM = 'b';
+var ALIGN_MIDDLE = 'm';
+var ALIGN_TOP = 't';
+
+var getWindowWidthGetters_ = {
+ ieQuirks_: function(win) {
+ return win.document.body.clientWidth;
+ },
+ ieStandards_: function(win) {
+ return win.document.documentElement.clientWidth;
+ },
+ dom_: function(win) {
+ return win.innerWidth;
+ },
+};
+
+function GetWindowHeight(win) {
+ return GetWindowPropertyByBrowser_(win, getWindowHeightGetters_);
+}
+
+var getWindowHeightGetters_ = {
+ ieQuirks_: function(win) {
+ return win.document.body.clientHeight;
+ },
+ ieStandards_: function(win) {
+ return win.document.documentElement.clientHeight;
+ },
+ dom_: function(win) {
+ return win.innerHeight;
+ },
+};
+
+/**
+ * Allows the easy use of different getters for IE quirks mode, IE standards
+ * mode and fully DOM-compliant browers.
+ *
+ * @param win window to get the property for
+ * @param getters object with various getters. Invoked with the passed window.
+ * There are three properties:
+ * - ieStandards_: IE 6.0 standards mode
+ * - ieQuirks_: IE 6.0 quirks mode and IE 5.5 and older
+ * - dom_: Mozilla, Safari and other fully DOM compliant browsers
+ *
+ * @private
+ */
+function GetWindowPropertyByBrowser_(win, getters) {
+ try {
+ if (BR_IsSafari()) {
+ return getters.dom_(win);
+ } else if (!window.opera &&
+ 'compatMode' in win.document &&
+ win.document.compatMode == 'CSS1Compat') {
+ return getters.ieStandards_(win);
+ } else if (BR_IsIE()) {
+ return getters.ieQuirks_(win);
+ }
+ } catch (e) {
+ // Ignore for now and fall back to DOM method
+ }
+
+ return getters.dom_(win);
+}
+
+function GetAvailScreenWidth(win) {
+ return win.screen.availWidth;
+}
+
+// Used for horizontally centering a new window of the given width in the
+// available screen. Set the new window's distance from the left of the screen
+// equal to this function's return value.
+// Params: width: the width of the new window
+// Returns: the distance from the left edge of the screen for the new window to
+// be horizontally centered
+function GetCenteringLeft(win, width) {
+ return (win.screen.availWidth - width) >> 1;
+}
+
+// Used for vertically centering a new window of the given height in the
+// available screen. Set the new window's distance from the top of the screen
+// equal to this function's return value.
+// Params: height: the height of the new window
+// Returns: the distance from the top edge of the screen for the new window to
+// be vertically aligned.
+function GetCenteringTop(win, height) {
+ return (win.screen.availHeight - height) >> 1;
+}
+
+/**
+ * Opens a child popup window that has no browser toolbar/decorations.
+ * (Copied from caribou's common.js library with small modifications.)
+ *
+ * @param url the URL for the new window (Note: this will be unique-ified)
+ * @param opt_name the name of the new window
+ * @param opt_width the width of the new window
+ * @param opt_height the height of the new window
+ * @param opt_center if true, the new window is centered in the available screen
+ * @param opt_hide_scrollbars if true, the window hides the scrollbars
+ * @param opt_noresize if true, makes window unresizable
+ * @param opt_blocked_msg message warning that the popup has been blocked
+ * @return {Window} a reference to the new child window
+ */
+function Popup(url, opt_name, opt_width, opt_height, opt_center,
+ opt_hide_scrollbars, opt_noresize, opt_blocked_msg) {
+ if (!opt_height) {
+ opt_height = Math.floor(GetWindowHeight(window.top) * 0.8);
+ }
+ if (!opt_width) {
+ opt_width = Math.min(GetAvailScreenWidth(window), opt_height);
+ }
+
+ let features = 'resizable=' + (opt_noresize ? 'no' : 'yes') + ',' +
+ 'scrollbars=' + (opt_hide_scrollbars ? 'no' : 'yes') + ',' +
+ 'width=' + opt_width + ',height=' + opt_height;
+ if (opt_center) {
+ features += ',left=' + GetCenteringLeft(window, opt_width) + ',' +
+ 'top=' + GetCenteringTop(window, opt_height);
+ }
+ return OpenWindow(window, url, opt_name, features, opt_blocked_msg);
+}
+
+/**
+ * Opens a new window. Returns the new window handle. Tries to open the new
+ * window using top.open() first. If that doesn't work, then tries win.open().
+ * If that still doesn't work, prints an alert.
+ * (Copied from caribou's common.js library with small modifications.)
+ *
+ * @param win the parent window from which to open the new child window
+ * @param url the URL for the new window (Note: this will be unique-ified)
+ * @param opt_name the name of the new window
+ * @param opt_features the properties of the new window
+ * @param opt_blocked_msg message warning that the popup has been blocked
+ * @return {Window} a reference to the new child window
+ */
+function OpenWindow(win, url, opt_name, opt_features, opt_blocked_msg) {
+ let newwin = OpenWindowHelper(top, url, opt_name, opt_features);
+ if (!newwin || newwin.closed || !newwin.focus) {
+ newwin = OpenWindowHelper(win, url, opt_name, opt_features);
+ }
+ if (!newwin || newwin.closed || !newwin.focus) {
+ if (opt_blocked_msg) alert(opt_blocked_msg);
+ } else {
+ // Make sure that the window has the focus
+ newwin.focus();
+ }
+ return newwin;
+}
+
+/*
+ * Helper for OpenWindow().
+ * (Copied from caribou's common.js library with small modifications.)
+ */
+function OpenWindowHelper(win, url, name, features) {
+ let newwin;
+ if (features) {
+ newwin = win.open(url, name, features);
+ } else if (name) {
+ newwin = win.open(url, name);
+ } else {
+ newwin = win.open(url);
+ }
+ return newwin;
+}
+
+// ------------------------------------------------------------------------
+// String utilities
+// ------------------------------------------------------------------------
+// Do html escaping
+var amp_re_ = /&/g;
+var lt_re_ = /</g;
+var gt_re_ = />/g;
+
+// converts multiple ws chars to a single space, and strips
+// leading and trailing ws
+var spc_re_ = /\s+/g;
+var beg_spc_re_ = /^ /;
+var end_spc_re_ = / $/;
+
+var newline_re_ = /\r?\n/g;
+var spctab_re_ = /[ \t]+/g;
+var nbsp_re_ = /\xa0/g;
+
+// URL-decodes the string. We need to specially handle '+'s because
+// the javascript library doesn't properly convert them to spaces
+var plus_re_ = /\+/g;
+
+// Converts any instances of "\r" or "\r\n" style EOLs into "\n" (Line Feed),
+// and also trim the extra newlines and whitespaces at the end.
+var eol_re_ = /\r\n?/g;
+var trailingspc_re_ = /[\n\t ]+$/;
+
+// Converts a string to its canonicalized label form.
+var illegal_chars_re_ = /[ \/(){}&|\\\"\000]/g;
+
+// ------------------------------------------------------------------------
+// TextArea utilities
+// ------------------------------------------------------------------------
+
+// Gets the cursor pos in a text area. Returns -1 if the cursor pos cannot
+// be determined or if the cursor out of the textfield.
+function GetCursorPos(win, textfield) {
+ try {
+ if (IsDefined(textfield.selectionEnd)) {
+ // Mozilla directly supports this
+ return textfield.selectionEnd;
+ } else if (win.document.selection && win.document.selection.createRange) {
+ // IE doesn't export an accessor for the endpoints of a selection.
+ // Instead, it uses the TextRange object, which has an extremely obtuse
+ // API. Here's what seems to work:
+
+ // (1) Obtain a textfield from the current selection (cursor)
+ let tr = win.document.selection.createRange();
+
+ // Check if the current selection is in the textfield
+ if (tr.parentElement() != textfield) {
+ return -1;
+ }
+
+ // (2) Make a text range encompassing the textfield
+ let tr2 = tr.duplicate();
+ tr2.moveToElementText(textfield);
+
+ // (3) Move the end of the copy to the beginning of the selection
+ tr2.setEndPoint('EndToStart', tr);
+
+ // (4) The span of the textrange copy is equivalent to the cursor pos
+ let cursor = tr2.text.length;
+
+ // Finally, perform a sanity check to make sure the cursor is in the
+ // textfield. IE sometimes screws this up when the window is activated
+ if (cursor > textfield.value.length) {
+ return -1;
+ }
+ return cursor;
+ } else {
+ Debug('Unable to get cursor position for: ' + navigator.userAgent);
+
+ // Just return the size of the textfield
+ // TODO: Investigate how to get cursor pos in Safari!
+ return textfield.value.length;
+ }
+ } catch (e) {
+ DumpException(e, 'Cannot get cursor pos');
+ }
+
+ return -1;
+}
+
+function SetCursorPos(win, textfield, pos) {
+ if (IsDefined(textfield.selectionEnd) &&
+ IsDefined(textfield.selectionStart)) {
+ // Mozilla directly supports this
+ textfield.selectionStart = pos;
+ textfield.selectionEnd = pos;
+ } else if (win.document.selection && textfield.createTextRange) {
+ // IE has textranges. A textfield's textrange encompasses the
+ // entire textfield's text by default
+ let sel = textfield.createTextRange();
+
+ sel.collapse(true);
+ sel.move('character', pos);
+ sel.select();
+ }
+}
+
+// ------------------------------------------------------------------------
+// Array utilities
+// ------------------------------------------------------------------------
+// Find an item in an array, returns the key, or -1 if not found
+function FindInArray(array, x) {
+ for (let i = 0; i < array.length; i++) {
+ if (array[i] == x) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// Delete an element from an array
+function DeleteArrayElement(array, x) {
+ let i = 0;
+ while (i < array.length && array[i] != x) {
+ i++;
+ }
+ array.splice(i, 1);
+}
+
+// Clean up email address:
+// - remove extra spaces
+// - Surround name with quotes if it contains special characters
+// to check if we need " quotes
+// Note: do not use /g in the regular expression, otherwise the
+// regular expression cannot be reusable.
+var specialchars_re_ = /[()<>@,;:\\\".\[\]]/;
+
+// ------------------------------------------------------------------------
+// Timeouts
+//
+// It is easy to forget to put a try/catch block around a timeout function,
+// and the result is an ugly user visible javascript error.
+// Also, it would be nice if a timeout associated with a window is
+// automatically cancelled when the user navigates away from that window.
+//
+// When storing timeouts in a window, we can't let that variable be renamed
+// since the window could be top.js, and renaming such a property could
+// clash with any of the variables/functions defined in top.js.
+// ------------------------------------------------------------------------
+/**
+ * Sets a timeout safely.
+ * @param win the window object. If null is passed in, then a timeout if set
+ * on the js frame. If the window is closed, or freed, the timeout is
+ * automaticaaly cancelled
+ * @param fn the callback function: fn(win) will be called.
+ * @param ms number of ms the callback should be called later
+ */
+function SafeTimeout(win, fn, ms) {
+ if (!win) win = window;
+ if (!win._tm) {
+ win._tm = [];
+ }
+ let timeoutfn = SafeTimeoutFunction_(win, fn);
+ let id = win.setTimeout(timeoutfn, ms);
+
+ // Save the id so that it can be removed from the _tm array
+ timeoutfn.id = id;
+
+ // Safe the timeout in the _tm array
+ win._tm[id] = 1;
+
+ return id;
+}
+
+/** Creates a callback function for a timeout*/
+function SafeTimeoutFunction_(win, fn) {
+ var timeoutfn = function() {
+ try {
+ fn(win);
+
+ let t = win._tm;
+ if (t) {
+ delete t[timeoutfn.id];
+ }
+ } catch (e) {
+ DumpException(e);
+ }
+ };
+ return timeoutfn;
+}
+
+// ------------------------------------------------------------------------
+// Misc
+// ------------------------------------------------------------------------
+// Check if a value is defined
+function IsDefined(value) {
+ return (typeof value) != 'undefined';
+}
diff --git a/static/js/graveyard/geom.js b/static/js/graveyard/geom.js
new file mode 100644
index 0000000..3eaffb7
--- /dev/null
+++ b/static/js/graveyard/geom.js
@@ -0,0 +1,94 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// functions for dealing with layout and geometry of page elements.
+// Requires shapes.js
+
+/** returns the bounding box of the given DOM node in document space.
+ *
+ * @param {Element?} obj a DOM node.
+ * @return {Rect?}
+ */
+function nodeBounds(obj) {
+ if (!obj) return null;
+
+ function fixRectForScrolling(r) {
+ // Need to take into account scrolling offset of ancestors (IE already does
+ // this)
+ for (let o = obj.offsetParent;
+ o && o.offsetParent;
+ o = o.offsetParent) {
+ if (o.scrollLeft) {
+ r.x -= o.scrollLeft;
+ }
+ if (o.scrollTop) {
+ r.y -= o.scrollTop;
+ }
+ }
+ }
+
+ let refWindow;
+ if (obj.ownerDocument && obj.ownerDocument.parentWindow) {
+ refWindow = obj.ownerDocument.parentWindow;
+ } else if (obj.ownerDocument && obj.ownerDocument.defaultView) {
+ refWindow = obj.ownerDocument.defaultView;
+ } else {
+ refWindow = window;
+ }
+
+ // IE, Mozilla 3+
+ if (obj.getBoundingClientRect) {
+ let rect = obj.getBoundingClientRect();
+
+ return new Rect(rect.left + GetScrollLeft(refWindow),
+ rect.top + GetScrollTop(refWindow),
+ rect.right - rect.left,
+ rect.bottom - rect.top,
+ refWindow);
+ }
+
+ // Mozilla < 3
+ if (obj.ownerDocument && obj.ownerDocument.getBoxObjectFor) {
+ let box = obj.ownerDocument.getBoxObjectFor(obj);
+ var r = new Rect(box.x, box.y, box.width, box.height, refWindow);
+ fixRectForScrolling(r);
+ return r;
+ }
+
+ // Fallback to recursively computing this
+ let left = 0;
+ let top = 0;
+ for (let o = obj; o.offsetParent; o = o.offsetParent) {
+ left += o.offsetLeft;
+ top += o.offsetTop;
+ }
+
+ var r = new Rect(left, top, obj.offsetWidth, obj.offsetHeight, refWindow);
+ fixRectForScrolling(r);
+ return r;
+}
+
+function GetMousePosition(e) {
+ // copied from http://www.quirksmode.org/js/events_compinfo.html
+ let posx = 0;
+ let posy = 0;
+ if (e.pageX || e.pageY) {
+ posx = e.pageX;
+ posy = e.pageY;
+ } else if (e.clientX || e.clientY) {
+ let obj = (e.target ? e.target : e.srcElement);
+ let refWindow;
+ if (obj.ownerDocument && obj.ownerDocument.parentWindow) {
+ refWindow = obj.ownerDocument.parentWindow;
+ } else {
+ refWindow = window;
+ }
+ posx = e.clientX + GetScrollLeft(refWindow);
+ posy = e.clientY + GetScrollTop(refWindow);
+ }
+ return new Point(posx, posy, window);
+}
diff --git a/static/js/graveyard/listen.js b/static/js/graveyard/listen.js
new file mode 100644
index 0000000..953d674
--- /dev/null
+++ b/static/js/graveyard/listen.js
@@ -0,0 +1,145 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+var listen;
+var unlisten;
+var unlistenByKey;
+
+(function() {
+ let listeners = {};
+ let nextId = 0;
+
+ function getHashCode_(obj) {
+ if (obj.listen_hc_ == null) {
+ obj.listen_hc_ = ++nextId;
+ }
+ return obj.listen_hc_;
+ }
+
+ /**
+ * Takes a node, event, listener, and capture flag to create a key
+ * to identify the tuple in the listeners hash.
+ *
+ * @param {Element} node The node to listen to events on.
+ * @param {string} event The name of the event without the "on" prefix.
+ * @param {Function} listener A function to call when the event occurs.
+ * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+ * whether the listener is fired during the
+ * capture or bubble phase of the event.
+ * @return {string} key to identify this tuple in the listeners hash.
+ */
+ function createKey_(node, event, listener, opt_useCapture) {
+ let nodeHc = getHashCode_(node);
+ let listenerHc = getHashCode_(listener);
+ opt_useCapture = !!opt_useCapture;
+ let key = nodeHc + '_' + event + '_' + listenerHc + '_' + opt_useCapture;
+ return key;
+ }
+
+ /**
+ * Adds an event listener to a DOM node for a specific event.
+ *
+ * Listen() and unlisten() use an indirect lookup of listener functions
+ * to avoid circular references between DOM (in IE) or XPCOM (in Mozilla)
+ * objects which leak memory. This makes it easier to write OO
+ * Javascript/DOM code.
+ *
+ * Examples:
+ * listen(myButton, 'click', myHandler, true);
+ * listen(myButton, 'click', this.myHandler.bind(this), true);
+ *
+ * @param {Element} node The node to listen to events on.
+ * @param {string} event The name of the event without the "on" prefix.
+ * @param {Function} listener A function to call when the event occurs.
+ * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+ * whether the listener is fired during the
+ * capture or bubble phase of the event.
+ * @return {string} a unique key to indentify this listener.
+ */
+ listen = function(node, event, listener, opt_useCapture) {
+ let key = createKey_(node, event, listener, opt_useCapture);
+
+ // addEventListener does not allow multiple listeners
+ if (key in listeners) {
+ return key;
+ }
+
+ let proxy = handleEvent.bind(null, key);
+ listeners[key] = {
+ listener: listener,
+ proxy: proxy,
+ event: event,
+ node: node,
+ useCapture: opt_useCapture,
+ };
+
+ if (node.addEventListener) {
+ node.addEventListener(event, proxy, opt_useCapture);
+ } else if (node.attachEvent) {
+ node.attachEvent('on' + event, proxy);
+ } else {
+ throw new Error('Node {' + node + '} does not support event listeners.');
+ }
+
+ return key;
+ };
+
+ /**
+ * Removes an event listener which was added with listen().
+ *
+ * @param {Element} node The node to stop listening to events on.
+ * @param {string} event The name of the event without the "on" prefix.
+ * @param {Function} listener The listener function to remove.
+ * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+ * whether the listener is fired during the
+ * capture or bubble phase of the event.
+ * @return {boolean} indicating whether the listener was there to remove.
+ */
+ unlisten = function(node, event, listener, opt_useCapture) {
+ let key = createKey_(node, event, listener, opt_useCapture);
+
+ return unlistenByKey(key);
+ };
+
+ /**
+ * Variant of {@link unlisten} that takes a key that was returned by
+ * {@link listen} and removes that listener.
+ *
+ * @param {string} key Key of event to be unlistened.
+ * @return {boolean} indicating whether it was there to be removed.
+ */
+ unlistenByKey = function(key) {
+ if (!(key in listeners)) {
+ return false;
+ }
+ let listener = listeners[key];
+ let proxy = listener.proxy;
+ let event = listener.event;
+ let node = listener.node;
+ let useCapture = listener.useCapture;
+
+ if (node.removeEventListener) {
+ node.removeEventListener(event, proxy, useCapture);
+ } else if (node.detachEvent) {
+ node.detachEvent('on' + event, proxy);
+ }
+
+ delete listeners[key];
+ return true;
+ };
+
+ /**
+ * The function which is actually called when the DOM event occurs. This
+ * function is a proxy for the real listener the user specified.
+ */
+ function handleEvent(key) {
+ // pass all arguments which were sent to this function except listenerID
+ // on to the actual listener.
+ let args = Array.prototype.splice.call(arguments, 1, arguments.length);
+ return listeners[key].listener.apply(null, args);
+ }
+})();
diff --git a/static/js/graveyard/popup_controller.js b/static/js/graveyard/popup_controller.js
new file mode 100644
index 0000000..41c2956
--- /dev/null
+++ b/static/js/graveyard/popup_controller.js
@@ -0,0 +1,145 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * It is common to make a DIV temporarily visible to simulate
+ * a popup window. Often, this is done by adding an onClick
+ * handler to the element that can be clicked on to show the
+ * popup.
+ *
+ * Unfortunately, closing the popup is not as simple.
+ * The popup creator often wants to let the user close
+ * the popup by clicking elsewhere on the window; however,
+ * the popup only receives mouse events that occur
+ * on the popup itself. Thus, popups need a mechanism
+ * that notifies them that the user has clicked elsewhere
+ * to try to get rid of them.
+ *
+ * PopupController is such a mechanism --
+ * it monitors all mousedown events that
+ * occur in the window so that it can notify registered
+ * popups of the mousedown, and the popups can choose
+ * to deactivate themselves.
+ *
+ * For an object to qualify as a popup, it must have a
+ * function called "deactivate" that takes a mousedown event
+ * and returns a boolean indicating that it has deactivated
+ * itself as a result of that event.
+ *
+ * EXAMPLE:
+ *
+ * // popup that attaches itself to the supplied div
+ * function MyPopup(div) {
+ * this._div = div;
+ * this._isVisible = false;
+ * this._innerHTML = ...
+ * }
+ *
+ * MyPopup.prototype.show = function() {
+ * this._div.display = '';
+ * this._isVisible = true;
+ * PC_addPopup(this);
+ * }
+ *
+ * MyPopup.prototype.hide = function() {
+ * this._div.display = 'none';
+ * this._isVisible = false;
+ * }
+ *
+ * MyPopup.prototype.deactivate = function(e) {
+ * if (this._isVisible) {
+ * var p = GetMousePosition(e);
+ * if (nodeBounds(this._div).contains(p)) {
+ * return false; // use clicked on popup, remain visible
+ * } else {
+ * this.hide();
+ * return true; // clicked outside popup, make invisible
+ * }
+ * } else {
+ * return true; // already deactivated, not visible
+ * }
+ * }
+ *
+ * DEPENDENCIES (from this directory):
+ * bind.js
+ * listen.js
+ * common.js
+ * shapes.js
+ * geom.js
+ *
+ * USAGE:
+ * _PC_Install() must be called after the body is loaded
+ */
+
+/**
+ * PopupController constructor.
+ * @constructor
+ */
+function PopupController() {
+ this.activePopups_ = [];
+}
+
+/**
+ * @param {Document} opt_doc document to add PopupController to
+ * DEFAULT: "document" variable that is currently in scope
+ * @return {boolean} indicating if PopupController installed for the document;
+ * returns false if document already had PopupController
+ */
+function _PC_Install(opt_doc) {
+ if (gPopupControllerInstalled) return false;
+ gPopupControllerInstalled = true;
+ let doc = (opt_doc) ? opt_doc : document;
+
+ // insert _notifyPopups in BODY's onmousedown chain
+ listen(doc.body, 'mousedown', PC_notifyPopups);
+ return true;
+}
+
+/**
+ * Notifies each popup of a mousedown event, giving
+ * each popup the chance to deactivate itself.
+ *
+ * @throws Error if a popup does not have a deactivate function
+ *
+ * @private
+ */
+function PC_notifyPopups(e) {
+ if (gPopupController.activePopups_.length == 0) return false;
+ e = e || window.event;
+ for (let i = gPopupController.activePopups_.length - 1; i >= 0; --i) {
+ let popup = gPopupController.activePopups_[i];
+ PC_assertIsPopup(popup);
+ if (popup.deactivate(e)) {
+ gPopupController.activePopups_.splice(i, 1);
+ }
+ }
+ return true;
+}
+
+/**
+ * Adds the popup to the list of popups to be
+ * notified of a mousedown event.
+ *
+ * @return boolean indicating if added popup; false if already contained
+ * @throws Error if popup does not have a deactivate function
+ */
+function PC_addPopup(popup) {
+ PC_assertIsPopup(popup);
+ for (let i = 0; i < gPopupController.activePopups_.length; ++i) {
+ if (popup === gPopupController.activePopups_[i]) return false;
+ }
+ gPopupController.activePopups_.push(popup);
+ return true;
+}
+
+/** asserts that popup has a deactivate function */
+function PC_assertIsPopup(popup) {
+ AssertType(popup.deactivate, Function, 'popup missing deactivate function');
+}
+
+var gPopupController = new PopupController();
+var gPopupControllerInstalled = false;
diff --git a/static/js/graveyard/shapes.js b/static/js/graveyard/shapes.js
new file mode 100644
index 0000000..27cd7f1
--- /dev/null
+++ b/static/js/graveyard/shapes.js
@@ -0,0 +1,126 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// shape related classes
+
+/** a point in 2 cartesian dimensions.
+ * @constructor
+ * @param x x-coord.
+ * @param y y-coord.
+ * @param opt_coordinateFrame a key that can be passed to a translation function to
+ * convert from one coordinate frame to another.
+ * Coordinate frames might correspond to things like windows, iframes, or
+ * any element with a position style attribute.
+ */
+function Point(x, y, opt_coordinateFrame) {
+ /** a numeric x coordinate. */
+ this.x = x;
+ /** a numeric y coordinate. */
+ this.y = y;
+ /** a key that can be passed to a translation function to
+ * convert from one coordinate frame to another.
+ * Coordinate frames might correspond to things like windows, iframes, or
+ * any element with a position style attribute.
+ */
+ this.coordinateFrame = opt_coordinateFrame || null;
+}
+Point.prototype.toString = function() {
+ return '[P ' + this.x + ',' + this.y + ']';
+};
+Point.prototype.clone = function() {
+ return new Point(this.x, this.y, this.coordinateFrame);
+};
+
+/** a distance between two points in 2-space in cartesian form.
+ * A delta doesn't have a coordinate frame associated since all the coordinate
+ * frames used in the HTML dom are convertible without rotation/scaling.
+ * If a delta is not being used in pixel-space then it may be annotated with
+ * a coordinate frame, and the undefined coordinate frame can be assumed
+ * to represent pixel space.
+ * @constructor
+ * @param dx distance along x axis
+ * @param dy distance along y axis
+ */
+function Delta(dx, dy) {
+ /** a numeric distance along the x dimension. */
+ this.dx = dx;
+ /** a numeric distance along the y dimension. */
+ this.dy = dy;
+}
+Delta.prototype.toString = function() {
+ return '[D ' + this.dx + ',' + this.dy + ']';
+};
+
+/** a rectangle or bounding region.
+ * @constructor
+ * @param x x-coord of the left edge.
+ * @param y y-coord of the top edge.
+ * @param w width.
+ * @param h height.
+ * @param opt_coordinateFrame a key that can be passed to a translation function to
+ * convert from one coordinate frame to another.
+ * Coordinate frames might correspond to things like windows, iframes, or
+ * any element with a position style attribute.
+ */
+function Rect(x, y, w, h, opt_coordinateFrame) {
+ /** the numeric x coordinate of the left edge. */
+ this.x = x;
+ /** the numeric y coordinate of the top edge. */
+ this.y = y;
+ /** the numeric distance between the right edge and the left. */
+ this.w = w;
+ /** the numeric distance between the top edge and the bottom. */
+ this.h = h;
+ /** a key that can be passed to a translation function to
+ * convert from one coordinate frame to another.
+ * Coordinate frames might correspond to things like windows, iframes, or
+ * any element with a position style attribute.
+ */
+ this.coordinateFrame = opt_coordinateFrame || null;
+}
+
+/**
+ * Determines whether the Rectangle contains the Point.
+ * The Point is considered "contained" if it lies
+ * on the boundary of, or in the interior of, the Rectangle.
+ *
+ * @param {Point} p
+ * @return boolean indicating if this Rect contains p
+ */
+Rect.prototype.contains = function(p) {
+ return this.x <= p.x && p.x < (this.x + this.w) &&
+ this.y <= p.y && p.y < (this.y + this.h);
+};
+
+/**
+ * Determines whether the given rectangle intersects this rectangle.
+ *
+ * @param {Rect} r
+ * @return boolean indicating if this the two rectangles intersect
+ */
+Rect.prototype.intersects = function(r) {
+ let p = function(x, y) {
+ return new Point(x, y, null);
+ };
+
+ return this.contains(p(r.x, r.y)) ||
+ this.contains(p(r.x + r.w, r.y)) ||
+ this.contains(p(r.x + r.w, r.y + r.h)) ||
+ this.contains(p(r.x, r.y + r.h)) ||
+ r.contains(p(this.x, this.y)) ||
+ r.contains(p(this.x + this.w, this.y)) ||
+ r.contains(p(this.x + this.w, this.y + this.h)) ||
+ r.contains(p(this.x, this.y + this.h));
+};
+
+Rect.prototype.toString = function() {
+ return '[R ' + this.w + 'x' + this.h + '+' + this.x + '+' + this.y + ']';
+};
+
+Rect.prototype.clone = function() {
+ return new Rect(this.x, this.y, this.w, this.h, this.coordinateFrame);
+};
diff --git a/static/js/graveyard/xmlhttp.js b/static/js/graveyard/xmlhttp.js
new file mode 100644
index 0000000..eaf1f36
--- /dev/null
+++ b/static/js/graveyard/xmlhttp.js
@@ -0,0 +1,141 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview A bunch of XML HTTP recipes used to do RPC from JavaScript
+ */
+
+
+/**
+ * The active x identifier used for ie.
+ * @type String
+ * @private
+ */
+var XH_ieProgId_;
+
+
+// Domain for XMLHttpRequest readyState
+var XML_READY_STATE_UNINITIALIZED = 0;
+var XML_READY_STATE_LOADING = 1;
+var XML_READY_STATE_LOADED = 2;
+var XML_READY_STATE_INTERACTIVE = 3;
+var XML_READY_STATE_COMPLETED = 4;
+
+
+/**
+ * Initialize the private state used by other functions.
+ * @private
+ */
+function XH_XmlHttpInit_() {
+ // The following blog post describes what PROG IDs to use to create the
+ // XMLHTTP object in Internet Explorer:
+ // http://blogs.msdn.com/xmlteam/archive/2006/10/23/using-the-right-version-of-msxml-in-internet-explorer.aspx
+ // However we do not (yet) fully trust that this will be OK for old versions
+ // of IE on Win9x so we therefore keep the last 2.
+ // Versions 4 and 5 have been removed because 3.0 is the preferred "fallback"
+ // per the article above.
+ // - Version 5 was built for Office applications and is not recommended for
+ // web applications.
+ // - Version 4 has been superseded by 6 and is only intended for legacy apps.
+ // - Version 3 has a wide install base and is serviced regularly with the OS.
+
+ /**
+ * Candidate Active X types.
+ * @type Array.<String>
+ * @private
+ */
+ let XH_ACTIVE_X_IDENTS = ['MSXML2.XMLHTTP.6.0', 'MSXML2.XMLHTTP.3.0',
+ 'MSXML2.XMLHTTP', 'Microsoft.XMLHTTP'];
+
+ if (typeof XMLHttpRequest == 'undefined' &&
+ typeof ActiveXObject != 'undefined') {
+ for (let i = 0; i < XH_ACTIVE_X_IDENTS.length; i++) {
+ let candidate = XH_ACTIVE_X_IDENTS[i];
+
+ try {
+ new ActiveXObject(candidate);
+ XH_ieProgId_ = candidate;
+ break;
+ } catch (e) {
+ // do nothing; try next choice
+ }
+ }
+
+ // couldn't find any matches
+ if (!XH_ieProgId_) {
+ throw Error('Could not create ActiveXObject. ActiveX might be disabled,' +
+ ' or MSXML might not be installed.');
+ }
+ }
+}
+
+
+XH_XmlHttpInit_();
+
+
+/**
+ * Create and return an xml http request object that can be passed to
+ * {@link #XH_XmlHttpGET} or {@link #XH_XmlHttpPOST}.
+ */
+function XH_XmlHttpCreate() {
+ if (XH_ieProgId_) {
+ return new ActiveXObject(XH_ieProgId_);
+ } else {
+ return new XMLHttpRequest();
+ }
+}
+
+
+/**
+ * Send a get request.
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string} url the service to contact
+ * @param {Function} handler function called when the response is received.
+ */
+function XH_XmlHttpGET(xmlHttp, url, handler) {
+ xmlHttp.open('GET', url, true);
+ xmlHttp.onreadystatechange = handler;
+ XH_XmlHttpSend(xmlHttp, null);
+}
+
+/**
+ * Send a post request.
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string} url the service to contact
+ * @param {string} data the request content.
+ * @param {Function} handler function called when the response is received.
+ */
+function XH_XmlHttpPOST(xmlHttp, url, data, handler) {
+ xmlHttp.open('POST', url, true);
+ xmlHttp.onreadystatechange = handler;
+ xmlHttp.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+ XH_XmlHttpSend(xmlHttp, data);
+}
+
+/**
+ * Calls 'send' on the XMLHttpRequest object and calls a function called 'log'
+ * if any error occured.
+ *
+ * @deprecated This dependes on a function called 'log'. You are better off
+ * handling your errors on application level.
+ *
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string|null} data the request content.
+ */
+function XH_XmlHttpSend(xmlHttp, data) {
+ try {
+ xmlHttp.send(data);
+ } catch (e) {
+ // You may want to log/debug this error one that you should be aware of is
+ // e.number == -2146697208, which occurs when the 'Languages...' setting in
+ // IE is empty.
+ // This is not entirely true. The same error code is used when the user is
+ // off line.
+ console.log('XMLHttpSend failed ' + e.toString() + '<br>' + e.stack);
+ throw e;
+ }
+}
diff --git a/static/js/hotlists/edit-hotlist.js b/static/js/hotlists/edit-hotlist.js
new file mode 100644
index 0000000..6e837a1
--- /dev/null
+++ b/static/js/hotlists/edit-hotlist.js
@@ -0,0 +1,35 @@
+/**
+ * Sets up the transfer ownership dialog box.
+ * @param {Long} hotlist_id id of the current hotlist
+*/
+function initializeDialogBox(hotlist_id) {
+ let transferContainer = $('transfer-ownership-container');
+ $('transfer-ownership').addEventListener('click', function() {
+ transferContainer.style.display = 'block';
+ });
+
+ let cancelButton = document.getElementById('cancel');
+
+ cancelButton.addEventListener('click', function() {
+ transferContainer.style.display = 'none';
+ });
+
+ $('hotlist_star').addEventListener('click', function() {
+ _TKR_toggleStar($('hotlist_star'), null, null, null, hotlist_id);
+ });
+}
+
+function initializeDialogBoxRemoveSelf() {
+ /* Initialise the dialog box for removing self from the hotlist. */
+
+ let removeSelfContainer = $('remove-self-container');
+ $('remove-self').addEventListener('click', function() {
+ removeSelfContainer.style.display = 'block';
+ });
+
+ let cancelButtonRS = document.getElementById('cancel-remove-self');
+
+ cancelButtonRS.addEventListener('click', function() {
+ removeSelfContainer.style.display = 'none';
+ });
+}
diff --git a/static/js/prettify.js b/static/js/prettify.js
new file mode 100644
index 0000000..7b99049
--- /dev/null
+++ b/static/js/prettify.js
@@ -0,0 +1,30 @@
+!function(){var q=null;window.PR_SHOULD_USE_CONTINUATION=!0;
+(function(){function S(a){function d(e){var b=e.charCodeAt(0);if(b!==92)return b;var a=e.charAt(1);return(b=r[a])?b:"0"<=a&&a<="7"?parseInt(e.substring(1),8):a==="u"||a==="x"?parseInt(e.substring(2),16):e.charCodeAt(1)}function g(e){if(e<32)return(e<16?"\\x0":"\\x")+e.toString(16);e=String.fromCharCode(e);return e==="\\"||e==="-"||e==="]"||e==="^"?"\\"+e:e}function b(e){var b=e.substring(1,e.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),e=[],a=
+b[0]==="^",c=["["];a&&c.push("^");for(var a=a?1:0,f=b.length;a<f;++a){var h=b[a];if(/\\[bdsw]/i.test(h))c.push(h);else{var h=d(h),l;a+2<f&&"-"===b[a+1]?(l=d(b[a+2]),a+=2):l=h;e.push([h,l]);l<65||h>122||(l<65||h>90||e.push([Math.max(65,h)|32,Math.min(l,90)|32]),l<97||h>122||e.push([Math.max(97,h)&-33,Math.min(l,122)&-33]))}}e.sort(function(e,a){return e[0]-a[0]||a[1]-e[1]});b=[];f=[];for(a=0;a<e.length;++a)h=e[a],h[0]<=f[1]+1?f[1]=Math.max(f[1],h[1]):b.push(f=h);for(a=0;a<b.length;++a)h=b[a],c.push(g(h[0])),
+h[1]>h[0]&&(h[1]+1>h[0]&&c.push("-"),c.push(g(h[1])));c.push("]");return c.join("")}function s(e){for(var a=e.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),c=a.length,d=[],f=0,h=0;f<c;++f){var l=a[f];l==="("?++h:"\\"===l.charAt(0)&&(l=+l.substring(1))&&(l<=h?d[l]=-1:a[f]=g(l))}for(f=1;f<d.length;++f)-1===d[f]&&(d[f]=++x);for(h=f=0;f<c;++f)l=a[f],l==="("?(++h,d[h]||(a[f]="(?:")):"\\"===l.charAt(0)&&(l=+l.substring(1))&&l<=h&&
+(a[f]="\\"+d[l]);for(f=0;f<c;++f)"^"===a[f]&&"^"!==a[f+1]&&(a[f]="");if(e.ignoreCase&&m)for(f=0;f<c;++f)l=a[f],e=l.charAt(0),l.length>=2&&e==="["?a[f]=b(l):e!=="\\"&&(a[f]=l.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return a.join("")}for(var x=0,m=!1,j=!1,k=0,c=a.length;k<c;++k){var i=a[k];if(i.ignoreCase)j=!0;else if(/[a-z]/i.test(i.source.replace(/\\u[\da-f]{4}|\\x[\da-f]{2}|\\[^UXux]/gi,""))){m=!0;j=!1;break}}for(var r={b:8,t:9,n:10,v:11,
+f:12,r:13},n=[],k=0,c=a.length;k<c;++k){i=a[k];if(i.global||i.multiline)throw Error(""+i);n.push("(?:"+s(i)+")")}return RegExp(n.join("|"),j?"gi":"g")}function T(a,d){function g(a){var c=a.nodeType;if(c==1){if(!b.test(a.className)){for(c=a.firstChild;c;c=c.nextSibling)g(c);c=a.nodeName.toLowerCase();if("br"===c||"li"===c)s[j]="\n",m[j<<1]=x++,m[j++<<1|1]=a}}else if(c==3||c==4)c=a.nodeValue,c.length&&(c=d?c.replace(/\r\n?/g,"\n"):c.replace(/[\t\n\r ]+/g," "),s[j]=c,m[j<<1]=x,x+=c.length,m[j++<<1|1]=
+a)}var b=/(?:^|\s)nocode(?:\s|$)/,s=[],x=0,m=[],j=0;g(a);return{a:s.join("").replace(/\n$/,""),d:m}}function H(a,d,g,b){d&&(a={a:d,e:a},g(a),b.push.apply(b,a.g))}function U(a){for(var d=void 0,g=a.firstChild;g;g=g.nextSibling)var b=g.nodeType,d=b===1?d?a:g:b===3?V.test(g.nodeValue)?a:d:d;return d===a?void 0:d}function C(a,d){function g(a){for(var j=a.e,k=[j,"pln"],c=0,i=a.a.match(s)||[],r={},n=0,e=i.length;n<e;++n){var z=i[n],w=r[z],t=void 0,f;if(typeof w==="string")f=!1;else{var h=b[z.charAt(0)];
+if(h)t=z.match(h[1]),w=h[0];else{for(f=0;f<x;++f)if(h=d[f],t=z.match(h[1])){w=h[0];break}t||(w="pln")}if((f=w.length>=5&&"lang-"===w.substring(0,5))&&!(t&&typeof t[1]==="string"))f=!1,w="src";f||(r[z]=w)}h=c;c+=z.length;if(f){f=t[1];var l=z.indexOf(f),B=l+f.length;t[2]&&(B=z.length-t[2].length,l=B-f.length);w=w.substring(5);H(j+h,z.substring(0,l),g,k);H(j+h+l,f,I(w,f),k);H(j+h+B,z.substring(B),g,k)}else k.push(j+h,w)}a.g=k}var b={},s;(function(){for(var g=a.concat(d),j=[],k={},c=0,i=g.length;c<i;++c){var r=
+g[c],n=r[3];if(n)for(var e=n.length;--e>=0;)b[n.charAt(e)]=r;r=r[1];n=""+r;k.hasOwnProperty(n)||(j.push(r),k[n]=q)}j.push(/[\S\s]/);s=S(j)})();var x=d.length;return g}function v(a){var d=[],g=[];a.tripleQuotedStrings?d.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?d.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/,
+q,"'\"`"]):d.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&g.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var b=a.hashComments;b&&(a.cStyleComments?(b>1?d.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):d.push(["com",/^#(?:(?:define|e(?:l|nd)if|else|error|ifn?def|include|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),g.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h(?:h|pp|\+\+)?|[a-z]\w*)>/,q])):d.push(["com",
+/^#[^\n\r]*/,q,"#"]));a.cStyleComments&&(g.push(["com",/^\/\/[^\n\r]*/,q]),g.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));if(b=a.regexLiterals){var s=(b=b>1?"":"\n\r")?".":"[\\S\\s]";g.push(["lang-regex",RegExp("^(?:^^\\.?|[+-]|[!=]=?=?|\\#|%=?|&&?=?|\\(|\\*=?|[+\\-]=|->|\\/=?|::?|<<?=?|>>?>?=?|,|;|\\?|@|\\[|~|{|\\^\\^?=?|\\|\\|?=?|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*("+("/(?=[^/*"+b+"])(?:[^/\\x5B\\x5C"+b+"]|\\x5C"+s+"|\\x5B(?:[^\\x5C\\x5D"+b+"]|\\x5C"+
+s+")*(?:\\x5D|$))+/")+")")])}(b=a.types)&&g.push(["typ",b]);b=(""+a.keywords).replace(/^ | $/g,"");b.length&&g.push(["kwd",RegExp("^(?:"+b.replace(/[\s,]+/g,"|")+")\\b"),q]);d.push(["pln",/^\s+/,q," \r\n\t\u00a0"]);b="^.[^\\s\\w.$@'\"`/\\\\]*";a.regexLiterals&&(b+="(?!s*/)");g.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,
+q],["pun",RegExp(b),q]);return C(d,g)}function J(a,d,g){function b(a){var c=a.nodeType;if(c==1&&!x.test(a.className))if("br"===a.nodeName)s(a),a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)b(a);else if((c==3||c==4)&&g){var d=a.nodeValue,i=d.match(m);if(i)c=d.substring(0,i.index),a.nodeValue=c,(d=d.substring(i.index+i[0].length))&&a.parentNode.insertBefore(j.createTextNode(d),a.nextSibling),s(a),c||a.parentNode.removeChild(a)}}function s(a){function b(a,c){var d=
+c?a.cloneNode(!1):a,e=a.parentNode;if(e){var e=b(e,1),g=a.nextSibling;e.appendChild(d);for(var i=g;i;i=g)g=i.nextSibling,e.appendChild(i)}return d}for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),d;(d=a.parentNode)&&d.nodeType===1;)a=d;c.push(a)}for(var x=/(?:^|\s)nocode(?:\s|$)/,m=/\r\n?|\n/,j=a.ownerDocument,k=j.createElement("li");a.firstChild;)k.appendChild(a.firstChild);for(var c=[k],i=0;i<c.length;++i)b(c[i]);d===(d|0)&&c[0].setAttribute("value",d);var r=j.createElement("ol");
+r.className="linenums";for(var d=Math.max(0,d-1|0)||0,i=0,n=c.length;i<n;++i)k=c[i],k.className="L"+(i+d)%10,k.firstChild||k.appendChild(j.createTextNode("\u00a0")),r.appendChild(k);a.appendChild(r)}function p(a,d){for(var g=d.length;--g>=0;){var b=d[g];F.hasOwnProperty(b)?D.console&&console.warn("cannot override language handler %s",b):F[b]=a}}function I(a,d){if(!a||!F.hasOwnProperty(a))a=/^\s*</.test(d)?"default-markup":"default-code";return F[a]}function K(a){var d=a.h;try{var g=T(a.c,a.i),b=g.a;
+a.a=b;a.d=g.d;a.e=0;I(d,b)(a);var s=/\bMSIE\s(\d+)/.exec(navigator.userAgent),s=s&&+s[1]<=8,d=/\n/g,x=a.a,m=x.length,g=0,j=a.d,k=j.length,b=0,c=a.g,i=c.length,r=0;c[i]=m;var n,e;for(e=n=0;e<i;)c[e]!==c[e+2]?(c[n++]=c[e++],c[n++]=c[e++]):e+=2;i=n;for(e=n=0;e<i;){for(var p=c[e],w=c[e+1],t=e+2;t+2<=i&&c[t+1]===w;)t+=2;c[n++]=p;c[n++]=w;e=t}c.length=n;var f=a.c,h;if(f)h=f.style.display,f.style.display="none";try{for(;b<k;){var l=j[b+2]||m,B=c[r+2]||m,t=Math.min(l,B),A=j[b+1],G;if(A.nodeType!==1&&(G=x.substring(g,
+t))){s&&(G=G.replace(d,"\r"));A.nodeValue=G;var L=A.ownerDocument,o=L.createElement("span");o.className=c[r+1];var v=A.parentNode;v.replaceChild(o,A);o.appendChild(A);g<l&&(j[b+1]=A=L.createTextNode(x.substring(t,l)),v.insertBefore(A,o.nextSibling))}g=t;g>=l&&(b+=2);g>=B&&(r+=2)}}finally{if(f)f.style.display=h}}catch(u){D.console&&console.log(u&&u.stack||u)}}var D=window,y=["break,continue,do,else,for,if,return,while"],E=[[y,"auto,case,char,const,default,double,enum,extern,float,goto,inline,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],
+"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],M=[E,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,delegate,dynamic_cast,explicit,export,friend,generic,late_check,mutable,namespace,nullptr,property,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],N=[E,"abstract,assert,boolean,byte,extends,final,finally,implements,import,instanceof,interface,null,native,package,strictfp,super,synchronized,throws,transient"],
+O=[N,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,internal,into,is,let,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var,virtual,where"],E=[E,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],P=[y,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],
+Q=[y,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],W=[y,"as,assert,const,copy,drop,enum,extern,fail,false,fn,impl,let,log,loop,match,mod,move,mut,priv,pub,pure,ref,self,static,struct,true,trait,type,unsafe,use"],y=[y,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],R=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)\b/,
+V=/\S/,X=v({keywords:[M,O,E,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",P,Q,y],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),F={};p(X,["default-code"]);p(C([],[["pln",/^[^<?]+/],["dec",/^<!\w[^>]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",
+/^<xmp\b[^>]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);p(C([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],
+["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css",/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);p(C([],[["atv",/^[\S\s]+/]]),["uq.val"]);p(v({keywords:M,hashComments:!0,cStyleComments:!0,types:R}),["c","cc","cpp","cxx","cyc","m"]);p(v({keywords:"null,true,false"}),["json"]);p(v({keywords:O,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:R}),
+["cs"]);p(v({keywords:N,cStyleComments:!0}),["java"]);p(v({keywords:y,hashComments:!0,multiLineStrings:!0}),["bash","bsh","csh","sh"]);p(v({keywords:P,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),["cv","py","python"]);p(v({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:2}),["perl","pl","pm"]);p(v({keywords:Q,
+hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb","ruby"]);p(v({keywords:E,cStyleComments:!0,regexLiterals:!0}),["javascript","js"]);p(v({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,throw,true,try,unless,until,when,while,yes",hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);p(v({keywords:W,cStyleComments:!0,multilineStrings:!0}),["rc","rs","rust"]);
+p(C([],[["str",/^[\S\s]+/]]),["regex"]);var Y=D.PR={createSimpleLexer:C,registerLangHandler:p,sourceDecorator:v,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ",prettyPrintOne:D.prettyPrintOne=function(a,d,g){var b=document.createElement("div");b.innerHTML="<pre>"+a+"</pre>";b=b.firstChild;g&&J(b,g,!0);K({h:d,j:g,c:b,i:1});
+return b.innerHTML},prettyPrint:D.prettyPrint=function(a,d){function g(){for(var b=D.PR_SHOULD_USE_CONTINUATION?c.now()+250:Infinity;i<p.length&&c.now()<b;i++){for(var d=p[i],j=h,k=d;k=k.previousSibling;){var m=k.nodeType,o=(m===7||m===8)&&k.nodeValue;if(o?!/^\??prettify\b/.test(o):m!==3||/\S/.test(k.nodeValue))break;if(o){j={};o.replace(/\b(\w+)=([\w%+\-.:]+)/g,function(a,b,c){j[b]=c});break}}k=d.className;if((j!==h||e.test(k))&&!v.test(k)){m=!1;for(o=d.parentNode;o;o=o.parentNode)if(f.test(o.tagName)&&
+o.className&&e.test(o.className)){m=!0;break}if(!m){d.className+=" prettyprinted";m=j.lang;if(!m){var m=k.match(n),y;if(!m&&(y=U(d))&&t.test(y.tagName))m=y.className.match(n);m&&(m=m[1])}if(w.test(d.tagName))o=1;else var o=d.currentStyle,u=s.defaultView,o=(o=o?o.whiteSpace:u&&u.getComputedStyle?u.getComputedStyle(d,q).getPropertyValue("white-space"):0)&&"pre"===o.substring(0,3);u=j.linenums;if(!(u=u==="true"||+u))u=(u=k.match(/\blinenums\b(?::(\d+))?/))?u[1]&&u[1].length?+u[1]:!0:!1;u&&J(d,u,o);r=
+{h:m,c:d,j:u,i:o};K(r)}}}i<p.length?setTimeout(g,250):"function"===typeof a&&a()}for(var b=d||document.body,s=b.ownerDocument||document,b=[b.getElementsByTagName("pre"),b.getElementsByTagName("code"),b.getElementsByTagName("xmp")],p=[],m=0;m<b.length;++m)for(var j=0,k=b[m].length;j<k;++j)p.push(b[m][j]);var b=q,c=Date;c.now||(c={now:function(){return+new Date}});var i=0,r,n=/\blang(?:uage)?-([\w.]+)(?!\S)/,e=/\bprettyprint\b/,v=/\bprettyprinted\b/,w=/pre|xmp/i,t=/^code$/i,f=/^(?:pre|code|xmp)$/i,
+h={};g()}};typeof define==="function"&&define.amd&&define("google-code-prettify",[],function(){return Y})})();}()
diff --git a/static/js/sitewide/linked-accounts.js b/static/js/sitewide/linked-accounts.js
new file mode 100644
index 0000000..e7fa7e1
--- /dev/null
+++ b/static/js/sitewide/linked-accounts.js
@@ -0,0 +1,80 @@
+/* Copyright 2019 The Chromium Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+const parentSelect = document.getElementById('parent_to_invite');
+const createButton = document.getElementById('create_linked_account_invite');
+const acceptButtons = document.querySelectorAll('.incoming_invite');
+const unlinkButtons = document.querySelectorAll('.unlink_account');
+
+function CreateLinkedAccountInvite(ev) {
+ const email = parentSelect.value;
+ const message = {
+ email: email,
+ };
+ const inviteCall = window.prpcClient.call(
+ 'monorail.Users', 'InviteLinkedParent', message);
+ inviteCall.then((resp) => {
+ location.reload();
+ }).catch((reason) => {
+ console.error('Inviting failed: ' + reason);
+ });
+}
+
+function AcceptIncomingInvite(ev) {
+ const email = ev.target.attributes['data-email'].value;
+ const message = {
+ email: email,
+ };
+ const acceptCall = window.prpcClient.call(
+ 'monorail.Users', 'AcceptLinkedChild', message);
+ acceptCall.then((resp) => {
+ location.reload();
+ }).catch((reason) => {
+ console.error('Accepting failed: ' + reason);
+ });
+}
+
+
+function UnlinkAccounts(ev) {
+ const parent = ev.target.dataset.parent;
+ const child = ev.target.dataset.child;
+ const message = {
+ parent: {display_name: parent},
+ child: {display_name: child},
+ };
+ const unlinkCall = window.prpcClient.call(
+ 'monorail.Users', 'UnlinkAccounts', message);
+ unlinkCall.then((resp) => {
+ location.reload();
+ }).catch((reason) => {
+ console.error('Unlinking failed: ' + reason);
+ });
+}
+
+
+if (parentSelect) {
+ parentSelect.onchange = function(e) {
+ const email = parentSelect.value;
+ createButton.disabled = email ? '' : 'disabled';
+ };
+}
+
+if (createButton) {
+ createButton.onclick = CreateLinkedAccountInvite;
+}
+
+if (acceptButtons) {
+ for (const acceptButton of acceptButtons) {
+ acceptButton.onclick = AcceptIncomingInvite;
+ }
+}
+
+if (unlinkButtons) {
+ for (const unlinkButton of unlinkButtons) {
+ unlinkButton.onclick = UnlinkAccounts;
+ }
+}
diff --git a/static/js/tracker/ac.js b/static/js/tracker/ac.js
new file mode 100644
index 0000000..4c0bf2b
--- /dev/null
+++ b/static/js/tracker/ac.js
@@ -0,0 +1,1010 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * An autocomplete library for javascript.
+ * Public API
+ * - _ac_install() install global handlers required for everything else to
+ * function.
+ * - _ac_register(SC) register a store constructor (see below)
+ * - _ac_isCompleting() true iff focus is in an auto complete box and the user
+ * has triggered completion with a keystroke, and completion has not been
+ * cancelled (programatically or otherwise).
+ * - _ac_isCompleteListShowing() true if _as_isCompleting and the complete list
+ * is visible to the user.
+ * - _ac_cancel() if completing, stop it, otherwise a no-op.
+ *
+ *
+ * A quick example
+ * // an auto complete store
+ * var myFavoritestAutoCompleteStore = new _AC_SimpleStore(
+ * ['some', 'strings', 'to', 'complete']);
+ *
+ * // a store constructor
+ * _ac_register(function (inputNode, keyEvent) {
+ * if (inputNode.id == 'my-auto-completing-check-box') {
+ * return myFavoritestAutoCompleteStore;
+ * }
+ * return null;
+ * });
+ *
+ * <html>
+ * <head>
+ * <script type=text/javascript src=ac.js></script>
+ * </head>
+ * <body onload=_ac_install()>
+ * <!-- the constructor above looks at the id. It could as easily
+ * - look at the class, name, or value.
+ * - The autocomplete=off stops browser autocomplete from
+ * - interfering with our autocomplete
+ * -->
+ * <input type=text id="my-auto-completing-check-box"
+ * autocomplete=off>
+ * </body>
+ * </html>
+ *
+ *
+ * Concepts
+ * - Store Constructor function
+ * A store constructor is a policy function with the signature
+ * _AC_Store myStoreConstructor(
+ * HtmlInputElement|HtmlTextAreaElement inputNode, Event keyEvent)
+ * When a key event is received on a text input or text area, the autocomplete
+ * library will try each of the store constructors in turn until it finds one
+ * that returns an AC_Store which will be used for auto-completion of that
+ * text box until focus is lost.
+ *
+ * - interface _AC_Store
+ * An autocomplete store encapsulates all operations that affect how a
+ * particular text node is autocompleted. It has the following operations:
+ * - String completable(String inputValue, int caret)
+ * This method returns null if not completable or the section of inputValue
+ * that is subject to completion. If autocomplete works on items in a
+ * comma separated list, then the input value "foo, ba" might yield "ba"
+ * as the completable chunk since it is separated from its predecessor by
+ * a comma.
+ * caret is the position of the text cursor (caret) in the text input.
+ * - _AC_Completion[] completions(String completable,
+ * _AC_Completion[] toFilter)
+ * This method returns null if there are no completions. If toFilter is
+ * not null or undefined, then this method may assume that toFilter was
+ * returned as a set of completions that contain completable.
+ * - String substitute(String inputValue, int caret,
+ * String completable, _AC_Completion completion)
+ * returns the inputValue with the given completion substituted for the
+ * given completable. caret has the same meaning as in the
+ * completable operation.
+ * - String oncomplete(boolean completed, String key,
+ * HTMLElement element, String text)
+ * This method is called when the user hits a completion key. The default
+ * value is to do nothing, but you can override it if you want. Note that
+ * key will be null if the user clicked on it to select
+ * - Boolean autoselectFirstRow()
+ * This method returns True by default, but subclasses can override it
+ * to make autocomplete fields that require the user to press the down
+ * arrow or do a mouseover once before any completion option is considered
+ * to be selected.
+ *
+ * - class _AC_SimpleStore
+ * An implementation of _AC_Store that completes a set of strings given at
+ * construct time in a text field with a comma separated value.
+ *
+ * - struct _AC_Completion
+ * a struct with two fields
+ * - String value : the plain text completion value
+ * - String html : the value, as html, with the completable in bold.
+ *
+ * Key Handling
+ * Several keys affect completion in an autocompleted input.
+ * ESC - the escape key cancels autocompleting. The autocompletion will have
+ * no effect on the focused textbox until it loses focus, regains it, and
+ * a key is pressed.
+ * ENTER - completes using the currently selected completion, or if there is
+ * only one, uses that completion.
+ * UP ARROW - selects the completion above the current selection.
+ * DOWN ARROW - selects the completion below the current selection.
+ *
+ *
+ * CSS styles
+ * The following CSS selector rules can be used to change the completion list
+ * look:
+ * #ac-list style of the auto-complete list
+ * #ac-list .selected style of the selected item
+ * #ac-list b style of the matching text in a candidate completion
+ *
+ * Dependencies
+ * The library depends on the following libraries:
+ * javascript:base for definition of key constants and SetCursorPos
+ * javascript:shapes for nodeBounds()
+ */
+
+/**
+ * install global handlers required for the rest of the module to function.
+ */
+function _ac_install() {
+ ac_addHandler_(document.body, 'onkeydown', ac_keyevent_);
+ ac_addHandler_(document.body, 'onkeypress', ac_keyevent_);
+}
+
+/**
+ * register a store constructor
+ * @param storeConstructor a function like
+ * _AC_Store myStoreConstructor(HtmlInputElement|HtmlTextArea, Event)
+ */
+function _ac_register(storeConstructor) {
+ // check that not already registered
+ for (let i = ac_storeConstructors.length; --i >= 0;) {
+ if (ac_storeConstructors[i] === storeConstructor) {
+ return;
+ }
+ }
+ ac_storeConstructors.push(storeConstructor);
+}
+
+/**
+ * may be attached as an onfocus handler to a text input to popup autocomplete
+ * immediately on the box gaining focus.
+ */
+function _ac_onfocus(event) {
+ ac_keyevent_(event);
+}
+
+/**
+ * true iff the autocomplete widget is currently active.
+ */
+function _ac_isCompleting() {
+ return !!ac_store && !ac_suppressCompletions;
+}
+
+/**
+ * true iff the completion list is displayed.
+ */
+function _ac_isCompleteListShowing() {
+ return !!ac_store && !ac_suppressCompletions && ac_completions &&
+ ac_completions.length;
+}
+
+/**
+ * cancel any autocomplete in progress.
+ */
+function _ac_cancel() {
+ ac_suppressCompletions = true;
+ ac_updateCompletionList(false);
+}
+
+/** add a handler without whacking any existing handler. @private */
+function ac_addHandler_(node, handlerName, handler) {
+ const oldHandler = node[handlerName];
+ if (!oldHandler) {
+ node[handlerName] = handler;
+ } else {
+ node[handlerName] = ac_fnchain_(node[handlerName], handler);
+ }
+ return oldHandler;
+}
+
+/** cancel the event. @private */
+function ac_cancelEvent_(event) {
+ if ('stopPropagation' in event) {
+ event.stopPropagation();
+ } else {
+ event.cancelBubble = true;
+ }
+
+ // This is handled in IE by returning false from the handler
+ if ('preventDefault' in event) {
+ event.preventDefault();
+ }
+}
+
+/** Call two functions, a and b, and return false if either one returns
+ false. This is used as a primitive way to attach multiple event
+ handlers to an element without using addEventListener(). This
+ library predates the availablity of addEventListener().
+ @private
+*/
+function ac_fnchain_(a, b) {
+ return function() {
+ const ar = a.apply(this, arguments);
+ const br = b.apply(this, arguments);
+
+ // NOTE 1: (undefined && false) -> undefined
+ // NOTE 2: returning FALSE from a onkeypressed cancels it,
+ // returning UNDEFINED does not.
+ // As such, we specifically look for falses here
+ if (ar === false || br === false) {
+ return false;
+ } else {
+ return true;
+ }
+ };
+}
+
+/** key press handler. @private */
+function ac_keyevent_(event) {
+ event = event || window.event;
+
+ const source = getTargetFromEvent(event);
+ const isInput = 'INPUT' == source.tagName &&
+ source.type.match(/^text|email$/i);
+ const isTextarea = 'TEXTAREA' == source.tagName;
+ if (!isInput && !isTextarea) return true;
+
+ const key = event.key;
+ const isDown = event.type == 'keydown';
+ const isShiftKey = event.shiftKey;
+ let storeFound = true;
+
+ if ((source !== ac_focusedInput) || (ac_store === null)) {
+ ac_focusedInput = source;
+ storeFound = false;
+ if (ENTER_KEYNAME !== key && ESC_KEYNAME !== key) {
+ for (let i = 0; i < ac_storeConstructors.length; ++i) {
+ const store = (ac_storeConstructors[i])(source, event);
+ if (store) {
+ ac_store = store;
+ ac_store.setAvoid(event);
+ ac_oldBlurHandler = ac_addHandler_(
+ ac_focusedInput, 'onblur', _ac_ob);
+ storeFound = true;
+ break;
+ }
+ }
+
+ // There exists an odd condition where an edit box with autocomplete
+ // attached can be removed from the DOM without blur being called
+ // In which case we are left with a store around that will try to
+ // autocomplete the next edit box to receive focus. We need to clean
+ // this up
+
+ // If we can't find a store, force a blur
+ if (!storeFound) {
+ _ac_ob(null);
+ }
+ }
+ // ac-table rows need to be removed when switching to another input.
+ ac_updateCompletionList(false);
+ }
+ // If the user typed Esc when the auto-complete menu was not shown,
+ // then blur the input text field so that the user can use keyboard
+ // shortcuts.
+ const acList = document.getElementById('ac-list');
+ if (ESC_KEYNAME == key &&
+ (!acList || acList.style.display == 'none')) {
+ ac_focusedInput.blur();
+ }
+
+ if (!storeFound) return true;
+
+ const isCompletion = ac_store.isCompletionKey(key, isDown, isShiftKey);
+ const hasResults = ac_completions && (ac_completions.length > 0);
+ let cancelEvent = false;
+
+ if (isCompletion && hasResults) {
+ // Cancel any enter keystrokes if something is selected so that the
+ // browser doesn't go submitting the form.
+ cancelEvent = (!ac_suppressCompletions && !!ac_completions &&
+ (ac_selected != -1));
+ window.setTimeout(function() {
+ if (ac_store) {
+ ac_handleKey_(key, isDown, isShiftKey);
+ }
+ }, 0);
+ } else if (!isCompletion) {
+ // Don't want to also blur the field. Up and down move the cursor (in
+ // Firefox) to the start/end of the field. We also don't want that while
+ // the list is showing.
+ cancelEvent = (key == ESC_KEYNAME ||
+ key == DOWN_KEYNAME ||
+ key == UP_KEYNAME);
+
+ window.setTimeout(function() {
+ if (ac_store) {
+ ac_handleKey_(key, isDown, isShiftKey);
+ }
+ }, 0);
+ } else { // implicit if (isCompletion && !hasResults)
+ if (ac_store.oncomplete) {
+ ac_store.oncomplete(false, key, ac_focusedInput, undefined);
+ }
+ }
+
+ if (cancelEvent) {
+ ac_cancelEvent_(event);
+ }
+
+ return !cancelEvent;
+}
+
+/** Autocomplete onblur handler. */
+function _ac_ob(event) {
+ if (ac_focusedInput) {
+ ac_focusedInput.onblur = ac_oldBlurHandler;
+ }
+ ac_store = null;
+ ac_focusedInput = null;
+ ac_everTyped = false;
+ ac_oldBlurHandler = null;
+ ac_suppressCompletions = false;
+ ac_updateCompletionList(false);
+}
+
+/** @constructor */
+function _AC_Store() {
+}
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.completable = function(inputValue, caret) {
+ console.log('UNIMPLEMENTED completable');
+};
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.completions = function(prefix, tofilter) {
+ console.log('UNIMPLEMENTED completions');
+};
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.oncomplete = function(completed, key, element, text) {
+ // Call the onkeyup handler so that choosing an autocomplete option has
+ // the same side-effect as typing. E.g., exposing the next row of input
+ // fields.
+ element.dispatchEvent(new Event('keyup'));
+ _ac_ob();
+};
+/** substitutes a completion for a completable in a text input's value. */
+_AC_Store.prototype.substitute =
+ function(inputValue, caret, completable, completion) {
+ console.log('UNIMPLEMENTED substitute');
+ };
+/** true iff hitting a comma key should complete. */
+_AC_Store.prototype.commaCompletes = true;
+/**
+ * true iff the given keystroke should cause a completion (and be consumed in
+ * the process.
+ */
+_AC_Store.prototype.isCompletionKey = function(key, isDown, isShiftKey) {
+ if (!isDown && (ENTER_KEYNAME === key ||
+ (COMMA_KEYNAME == key && this.commaCompletes))) {
+ return true;
+ }
+ if (TAB_KEYNAME === key && !isShiftKey) {
+ // IE doesn't fire an event for tab on click in a text field, and firefox
+ // requires that the onkeypress event for tab be consumed or it navigates
+ // to next field.
+ return false;
+ // JER: return isDown == BR_IsIE();
+ }
+ return false;
+};
+
+_AC_Store.prototype.setAvoid = function(event) {
+ if (event && event.avoidValues) {
+ ac_avoidValues = event.avoidValues;
+ } else {
+ ac_avoidValues = this.computeAvoid();
+ }
+ ac_avoidValues = ac_avoidValues.map((val) => val.toLowerCase());
+};
+
+/* Subclasses may implement this to compute values to avoid
+ offering in the current input field, i.e., because those
+ values are already used. */
+_AC_Store.prototype.computeAvoid = function() {
+ return [];
+};
+
+
+function _AC_AddItemToFirstCharMap(firstCharMap, ch, s) {
+ let l = firstCharMap[ch];
+ if (!l) {
+ l = firstCharMap[ch] = [];
+ } else if (l[l.length - 1].value == s) {
+ return;
+ }
+ l.push(new _AC_Completion(s, null, ''));
+}
+
+/**
+ * an _AC_Store implementation suitable for completing lists of email
+ * addresses.
+ * @constructor
+ */
+function _AC_SimpleStore(strings, opt_docStrings) {
+ this.firstCharMap_ = {};
+
+ for (let i = 0; i < strings.length; ++i) {
+ let s = strings[i];
+ if (!s) {
+ continue;
+ }
+ if (opt_docStrings && opt_docStrings[s]) {
+ s = s + ' ' + opt_docStrings[s];
+ }
+
+ const parts = s.split(/\W+/);
+ for (let j = 0; j < parts.length; ++j) {
+ if (parts[j]) {
+ _AC_AddItemToFirstCharMap(
+ this.firstCharMap_, parts[j].charAt(0).toLowerCase(), strings[i]);
+ }
+ }
+ }
+
+ // The maximimum number of results that we are willing to show
+ this.countThreshold = 2500;
+ this.docstrings = opt_docStrings || {};
+}
+_AC_SimpleStore.prototype = new _AC_Store();
+_AC_SimpleStore.prototype.constructor = _AC_SimpleStore;
+
+_AC_SimpleStore.prototype.completable =
+ function(inputValue, caret) {
+ // complete after the last comma not inside ""s
+ let start = 0;
+ let state = 0;
+ for (let i = 0; i < caret; ++i) {
+ const ch = inputValue.charAt(i);
+ switch (state) {
+ case 0:
+ if ('"' == ch) {
+ state = 1;
+ } else if (',' == ch || ' ' == ch) {
+ start = i + 1;
+ }
+ break;
+ case 1:
+ if ('"' == ch) {
+ state = 0;
+ }
+ break;
+ }
+ }
+ while (start < caret &&
+ ' \t\r\n'.indexOf(inputValue.charAt(start)) >= 0) {
+ ++start;
+ }
+ return inputValue.substring(start, caret);
+ };
+
+
+/** Simple function to create a <span> with matching text in bold.
+ */
+function _AC_CreateSpanWithMatchHighlighted(match) {
+ const span = document.createElement('span');
+ span.appendChild(document.createTextNode(match[1] || ''));
+ const bold = document.createElement('b');
+ span.appendChild(bold);
+ bold.appendChild(document.createTextNode(match[2]));
+ span.appendChild(document.createTextNode(match[3] || ''));
+ return span;
+};
+
+
+/**
+ * Get all completions matching the given prefix.
+ * @param {string} prefix The prefix of the text to autocomplete on.
+ * @param {List.<string>?} toFilter Optional list to filter on. Otherwise will
+ * use this.firstCharMap_ using the prefix's first character.
+ * @return {List.<_AC_Completion>} The computed list of completions.
+ */
+_AC_SimpleStore.prototype.completions = function(prefix) {
+ if (!prefix) {
+ return [];
+ }
+ toFilter = this.firstCharMap_[prefix.charAt(0).toLowerCase()];
+
+ // Since we use prefix to build a regular expression, we need to escape RE
+ // characters. We match '-', '{', '$' and others in the prefix and convert
+ // them into "\-", "\{", "\$".
+ const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
+ const modifiedPrefix = prefix.replace(regexForRegexCharacters, '\\$1');
+
+ // Match the modifiedPrefix anywhere as long as it is either at the very
+ // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
+ // such as "Ga" -> "The-Great-Gatsby".
+ const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
+ const pattern = new RegExp(patternRegex, 'i' /* ignore case */);
+
+ // We keep separate lists of possible completions that were generated
+ // by matching a value or generated by matching a docstring. We return
+ // a concatenated list so that value matches all come before docstring
+ // matches.
+ const completions = [];
+ const docCompletions = [];
+
+ if (toFilter) {
+ const toFilterLength = toFilter.length;
+ for (let i = 0; i < toFilterLength; ++i) {
+ const docStr = this.docstrings[toFilter[i].value];
+ let compSpan = null;
+ let docSpan = null;
+ const matches = toFilter[i].value.match(pattern);
+ const docMatches = docStr && docStr.match(pattern);
+ if (matches) {
+ compSpan = _AC_CreateSpanWithMatchHighlighted(matches);
+ if (docStr) docSpan = document.createTextNode(docStr);
+ } else if (docMatches) {
+ compSpan = document.createTextNode(toFilter[i].value);
+ docSpan = _AC_CreateSpanWithMatchHighlighted(docMatches);
+ }
+
+ if (compSpan) {
+ const newCompletion = new _AC_Completion(
+ toFilter[i].value, compSpan, docSpan);
+
+ if (matches) {
+ completions.push(newCompletion);
+ } else {
+ docCompletions.push(newCompletion);
+ }
+ if (completions.length + docCompletions.length > this.countThreshold) {
+ break;
+ }
+ }
+ }
+ }
+
+ return completions.concat(docCompletions);
+};
+
+// Normally, when the user types a few characters, we aggressively
+// select the first possible completion (if any). When the user
+// hits ENTER, that first completion is substituted. When that
+// behavior is not desired, override this to return false.
+_AC_SimpleStore.prototype.autoselectFirstRow = function() {
+ return true;
+};
+
+// Comparison function for _AC_Completion
+function _AC_CompareACCompletion(a, b) {
+ // convert it to lower case and remove all leading junk
+ const aval = a.value.toLowerCase().replace(/^\W*/, '');
+ const bval = b.value.toLowerCase().replace(/^\W*/, '');
+
+ if (a.value === b.value) {
+ return 0;
+ } else if (aval < bval) {
+ return -1;
+ } else {
+ return 1;
+ }
+}
+
+_AC_SimpleStore.prototype.substitute =
+function(inputValue, caret, completable, completion) {
+ return inputValue.substring(0, caret - completable.length) +
+ completion.value + ', ' + inputValue.substring(caret);
+};
+
+/**
+ * a possible completion.
+ * @constructor
+ */
+function _AC_Completion(value, compSpan, docSpan) {
+ /** plain text. */
+ this.value = value;
+ if (typeof compSpan == 'string') compSpan = document.createTextNode(compSpan);
+ this.compSpan = compSpan;
+ if (typeof docSpan == 'string') docSpan = document.createTextNode(docSpan);
+ this.docSpan = docSpan;
+}
+_AC_Completion.prototype.toString = function() {
+ return '(AC_Completion: ' + this.value + ')';
+};
+
+/** registered store constructors. @private */
+var ac_storeConstructors = [];
+/**
+ * the focused text input or textarea whether store is null or not.
+ * A text input may have focus and this may be null iff no key has been typed in
+ * the text input.
+ */
+var ac_focusedInput = null;
+/**
+ * null or the autocomplete store used to complete ac_focusedInput.
+ * @private
+ */
+var ac_store = null;
+/** store handler from ac_focusedInput. @private */
+var ac_oldBlurHandler = null;
+/**
+ * true iff user has indicated completions are unwanted (via ESC key)
+ * @private
+ */
+var ac_suppressCompletions = false;
+/**
+ * chunk of completable text seen last keystroke.
+ * Used to generate ac_completions.
+ * @private
+ */
+let ac_lastCompletable = null;
+/** an array of _AC_Completions. @private */
+var ac_completions = null;
+/** -1 or in [0, _AC_Completions.length). @private */
+var ac_selected = -1;
+
+/** Maximum number of options displayed in menu. @private */
+const ac_max_options = 100;
+
+/** Don't offer these values because they are already used. @private */
+let ac_avoidValues = [];
+
+/**
+ * handles all the key strokes, updating the completion list, tracking selected
+ * element, performing substitutions, etc.
+ * @private
+ */
+function ac_handleKey_(key, isDown, isShiftKey) {
+ // check completions
+ ac_checkCompletions();
+ let show = true;
+ const numCompletions = ac_completions ? ac_completions.length : 0;
+ // handle enter and tab on key press and the rest on key down
+ if (ac_store.isCompletionKey(key, isDown, isShiftKey)) {
+ if (ac_selected < 0 && numCompletions >= 1 &&
+ ac_store.autoselectFirstRow()) {
+ ac_selected = 0;
+ }
+ if (ac_selected >= 0) {
+ const backupInput = ac_focusedInput;
+ const completeValue = ac_completions[ac_selected].value;
+ ac_complete();
+ if (ac_store.oncomplete) {
+ ac_store.oncomplete(true, key, backupInput, completeValue);
+ }
+ }
+ } else {
+ switch (key) {
+ case ESC_KEYNAME: // escape
+ // JER?? ac_suppressCompletions = true;
+ ac_selected = -1;
+ show = false;
+ break;
+ case UP_KEYNAME: // up
+ if (isDown) {
+ // firefox fires arrow events on both down and press, but IE only fires
+ // then on press.
+ ac_selected = Math.max(numCompletions >= 0 ? 0 : -1, ac_selected - 1);
+ }
+ break;
+ case DOWN_KEYNAME: // down
+ if (isDown) {
+ ac_selected = Math.min(
+ ac_max_options - 1, Math.min(numCompletions - 1, ac_selected + 1));
+ }
+ break;
+ }
+
+ if (isDown) {
+ switch (key) {
+ case ESC_KEYNAME:
+ case ENTER_KEYNAME:
+ case UP_KEYNAME:
+ case DOWN_KEYNAME:
+ case RIGHT_KEYNAME:
+ case LEFT_KEYNAME:
+ case TAB_KEYNAME:
+ case SHIFT_KEYNAME:
+ case BACKSPACE_KEYNAME:
+ case DELETE_KEYNAME:
+ break;
+ default: // User typed some new characters.
+ ac_everTyped = true;
+ }
+ }
+ }
+
+ if (ac_focusedInput) {
+ ac_updateCompletionList(show);
+ }
+}
+
+/**
+ * called when an option is clicked on to select that option.
+ */
+function _ac_select(optionIndex) {
+ ac_selected = optionIndex;
+ ac_complete();
+ if (ac_store.oncomplete) {
+ ac_store.oncomplete(true, null, ac_focusedInput, ac_focusedInput.value);
+ }
+
+ // check completions
+ ac_checkCompletions();
+ ac_updateCompletionList(true);
+}
+
+function _ac_mouseover(optionIndex) {
+ ac_selected = optionIndex;
+ ac_updateCompletionList(true);
+}
+
+/** perform the substitution of the currently selected item. */
+function ac_complete() {
+ const caret = ac_getCaretPosition_(ac_focusedInput);
+ const completion = ac_completions[ac_selected];
+
+ ac_focusedInput.value = ac_store.substitute(
+ ac_focusedInput.value, caret,
+ ac_lastCompletable, completion);
+ // When the prefix starts with '*' we want to return the complete set of all
+ // possible completions. We treat the ac_lastCompletable value as empty so
+ // that the caret is correctly calculated (i.e. the caret should not consider
+ // placeholder values like '*member').
+ let new_caret = caret + completion.value.length;
+ if (!ac_lastCompletable.startsWith('*')) {
+ // Only consider the ac_lastCompletable length if it does not start with '*'
+ new_caret = new_caret - ac_lastCompletable.length;
+ }
+ // If we inserted something ending in two quotation marks, position
+ // the cursor between the quotation marks. If we inserted a complete term,
+ // skip over the trailing space so that the user is ready to enter the next
+ // term. If we inserted just a search operator, leave the cursor immediately
+ // after the colon or equals and don't skip over the space.
+ if (completion.value.substring(completion.value.length - 2) == '""') {
+ new_caret--;
+ } else if (completion.value.substring(completion.value.length - 1) != ':' &&
+ completion.value.substring(completion.value.length - 1) != '=') {
+ new_caret++; // To account for the comma.
+ new_caret++; // To account for the space after the comma.
+ }
+ ac_selected = -1;
+ ac_completions = null;
+ ac_lastCompletable = null;
+ ac_everTyped = false;
+ SetCursorPos(window, ac_focusedInput, new_caret);
+}
+
+/**
+ * True if the user has ever typed any actual characters in the currently
+ * focused text field. False if they have only clicked, backspaced, and
+ * used the arrow keys.
+ */
+var ac_everTyped = false;
+
+/**
+ * maintains ac_completions, ac_selected, ac_lastCompletable.
+ * @private
+ */
+function ac_checkCompletions() {
+ if (ac_focusedInput && !ac_suppressCompletions) {
+ const caret = ac_getCaretPosition_(ac_focusedInput);
+ const completable = ac_store.completable(ac_focusedInput.value, caret);
+
+ // If we already have completed, then our work here is done.
+ if (completable == ac_lastCompletable) {
+ return;
+ }
+
+ ac_completions = null;
+ ac_selected = -1;
+
+ const oldSelected =
+ ((ac_selected >= 0 && ac_selected < ac_completions.length) ?
+ ac_completions[ac_selected].value : null);
+ ac_completions = ac_store.completions(completable);
+ // Don't offer options for values that the user has already used
+ // in another part of the current form.
+ ac_completions = ac_completions.filter((comp) =>
+ FindInArray(ac_avoidValues, comp.value.toLowerCase()) === -1);
+
+ ac_selected = oldSelected ? 0 : -1;
+ ac_lastCompletable = completable;
+ return;
+ }
+ ac_lastCompletable = null;
+ ac_completions = null;
+ ac_selected = -1;
+}
+
+/**
+ * maintains the completion list GUI.
+ * @private
+ */
+function ac_updateCompletionList(show) {
+ let clist = document.getElementById('ac-list');
+ const input = ac_focusedInput;
+ if (input) {
+ input.setAttribute('aria-activedescendant', 'ac-status-row-none');
+ }
+ let tableEl;
+ let tableBody;
+ if (show && ac_completions && ac_completions.length) {
+ if (!clist) {
+ clist = document.createElement('DIV');
+ clist.id = 'ac-list';
+ clist.style.position = 'absolute';
+ clist.style.display = 'none';
+ // with 'listbox' and 'option' roles, screenreader narrates total
+ // number of options eg. 'New = issue has not .... 1 of 9'
+ document.body.appendChild(clist);
+ tableEl = document.createElement('table');
+ tableEl.setAttribute('cellpadding', 0);
+ tableEl.setAttribute('cellspacing', 0);
+ tableEl.id = 'ac-table';
+ tableEl.setAttribute('role', 'presentation');
+ tableBody = document.createElement('tbody');
+ tableBody.id = 'ac-table-body';
+ tableEl.appendChild(tableBody);
+ tableBody.setAttribute('role', 'listbox');
+ clist.appendChild(tableEl);
+ input.setAttribute('aria-controls', 'ac-table');
+ input.setAttribute('aria-haspopup', 'grid');
+ } else {
+ tableEl = document.getElementById('ac-table');
+ tableBody = document.getElementById('ac-table-body');
+ while (tableBody.childNodes.length) {
+ tableBody.removeChild(tableBody.childNodes[0]);
+ }
+ }
+
+ // If no choice is selected, then select the first item, if desired.
+ if (ac_selected < 0 && ac_store && ac_store.autoselectFirstRow()) {
+ ac_selected = 0;
+ }
+
+ let headerCount= 0;
+ for (let i = 0; i < Math.min(ac_max_options, ac_completions.length); ++i) {
+ if (ac_completions[i].heading) {
+ var rowEl = document.createElement('tr');
+ tableBody.appendChild(rowEl);
+ const cellEl = document.createElement('th');
+ rowEl.appendChild(cellEl);
+ cellEl.setAttribute('colspan', 2);
+ if (headerCount) {
+ cellEl.appendChild(document.createElement('br'));
+ }
+ cellEl.appendChild(
+ document.createTextNode(ac_completions[i].heading));
+ headerCount++;
+ } else {
+ var rowEl = document.createElement('tr');
+ tableBody.appendChild(rowEl);
+ if (i == ac_selected) {
+ rowEl.className = 'selected';
+ }
+ rowEl.id = `ac-status-row-${i}`;
+ rowEl.setAttribute('data-index', i);
+ rowEl.setAttribute('role', 'option');
+ rowEl.addEventListener('mousedown', function(event) {
+ event.preventDefault();
+ });
+ rowEl.addEventListener('mouseup', function(event) {
+ let target = event.target;
+ while (target && target.tagName != 'TR') {
+ target = target.parentNode;
+ }
+ const idx = Number(target.getAttribute('data-index'));
+ try {
+ _ac_select(idx);
+ } finally {
+ return false;
+ }
+ });
+ rowEl.addEventListener('mouseover', function(event) {
+ let target = event.target;
+ while (target && target.tagName != 'TR') {
+ target = target.parentNode;
+ }
+ const idx = Number(target.getAttribute('data-index'));
+ _ac_mouseover(idx);
+ });
+ const valCellEl = document.createElement('td');
+ rowEl.appendChild(valCellEl);
+ if (ac_completions[i].compSpan) {
+ valCellEl.appendChild(ac_completions[i].compSpan);
+ }
+ const docCellEl = document.createElement('td');
+ rowEl.appendChild(docCellEl);
+ if (ac_completions[i].docSpan &&
+ ac_completions[i].docSpan.textContent) {
+ docCellEl.appendChild(document.createTextNode(' = '));
+ docCellEl.appendChild(ac_completions[i].docSpan);
+ }
+ }
+ }
+
+ // position
+ const inputBounds = nodeBounds(ac_focusedInput);
+ clist.style.left = inputBounds.x + 'px';
+ clist.style.top = (inputBounds.y + inputBounds.h) + 'px';
+
+ window.setTimeout(ac_autoscroll, 100);
+ input.setAttribute('aria-activedescendant', `ac-status-row-${ac_selected}`);
+ // Note - we use '' instead of 'block', since 'block' has odd effects on
+ // the screen in IE, and causes scrollbars to resize
+ clist.style.display = '';
+ } else {
+ tableBody = document.getElementById('ac-table-body');
+ if (clist && tableBody) {
+ clist.style.display = 'none';
+ while (tableBody.childNodes.length) {
+ tableBody.removeChild(tableBody.childNodes[0]);
+ }
+ }
+ }
+}
+
+// TODO(jrobbins): make arrow keys and mouse not conflict if they are
+// used at the same time.
+
+
+/** Scroll the autocomplete menu to show the currently selected row. */
+function ac_autoscroll() {
+ const acList = document.getElementById('ac-list');
+ const acSelRow = acList.getElementsByClassName('selected')[0];
+ const acSelRowTop = acSelRow ? acSelRow.offsetTop : 0;
+ const acSelRowHeight = acSelRow ? acSelRow.offsetHeight : 0;
+
+
+ const EXTRA = 8; // Go an extra few pixels so the next row is partly exposed.
+
+ if (!acList || !acSelRow) return;
+
+ // Autoscroll upward if the selected item is above the visible area,
+ // else autoscroll downward if the selected item is below the visible area.
+ if (acSelRowTop < acList.scrollTop) {
+ acList.scrollTop = acSelRowTop - EXTRA;
+ } else if (acSelRowTop + acSelRowHeight + EXTRA >
+ acList.scrollTop + acList.offsetHeight) {
+ acList.scrollTop = (acSelRowTop + acSelRowHeight -
+ acList.offsetHeight + EXTRA);
+ }
+}
+
+
+/** the position of the text caret in the given text field.
+ *
+ * @param textField an INPUT node with type=text or a TEXTAREA node
+ * @return an index in [0, textField.value.length]
+ */
+function ac_getCaretPosition_(textField) {
+ if ('INPUT' == textField.tagName) {
+ let caret = textField.value.length;
+
+ // chrome/firefox
+ if (undefined != textField.selectionStart) {
+ caret = textField.selectionEnd;
+
+ // JER: Special treatment for issue status field that makes all
+ // options show up more often
+ if (textField.id.startsWith('status')) {
+ caret = textField.selectionStart;
+ }
+ // ie
+ } else if (document.selection) {
+ // get an empty selection range
+ const range = document.selection.createRange();
+ const origSelectionLength = range.text.length;
+ // Force selection start to 0 position
+ range.moveStart('character', -caret);
+ // the caret end position is the new selection length
+ caret = range.text.length;
+
+ // JER: Special treatment for issue status field that makes all
+ // options show up more often
+ if (textField.id.startsWith('status')) {
+ // The amount that the selection grew when we forced start to
+ // position 0 is == the original start position.
+ caret = range.text.length - origSelectionLength;
+ }
+ }
+
+ return caret;
+ } else {
+ // a textarea
+
+ return GetCursorPos(window, textField);
+ }
+}
+
+function getTargetFromEvent(event) {
+ let targ = event.target || event.srcElement;
+ if (targ.shadowRoot) {
+ // Find the element within the shadowDOM.
+ const path = event.path || event.composedPath();
+ targ = path[0];
+ }
+ return targ;
+}
diff --git a/static/js/tracker/ac_test.js b/static/js/tracker/ac_test.js
new file mode 100644
index 0000000..30eedc5
--- /dev/null
+++ b/static/js/tracker/ac_test.js
@@ -0,0 +1,40 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+var firstCharMap;
+
+function setUp() {
+ firstCharMap = new Object();
+}
+
+function testAddItemToFirstCharMap_OneWordLabel() {
+ _AC_AddItemToFirstCharMap(firstCharMap, 'h', 'Hot');
+ let hArray = firstCharMap['h'];
+ assertEquals(1, hArray.length);
+ assertEquals('Hot', hArray[0].value);
+
+ _AC_AddItemToFirstCharMap(firstCharMap, '-', '-Hot');
+ _AC_AddItemToFirstCharMap(firstCharMap, 'h', '-Hot');
+ let minusArray = firstCharMap['-'];
+ assertEquals(1, minusArray.length);
+ assertEquals('-Hot', minusArray[0].value);
+ hArray = firstCharMap['h'];
+ assertEquals(2, hArray.length);
+ assertEquals('Hot', hArray[0].value);
+ assertEquals('-Hot', hArray[1].value);
+}
+
+function testAddItemToFirstCharMap_KeyValueLabels() {
+ _AC_AddItemToFirstCharMap(firstCharMap, 'p', 'Priority-High');
+ _AC_AddItemToFirstCharMap(firstCharMap, 'h', 'Priority-High');
+ let pArray = firstCharMap['p'];
+ assertEquals(1, pArray.length);
+ assertEquals('Priority-High', pArray[0].value);
+ let hArray = firstCharMap['h'];
+ assertEquals(1, hArray.length);
+ assertEquals('Priority-High', hArray[0].value);
+}
diff --git a/static/js/tracker/externs.js b/static/js/tracker/externs.js
new file mode 100644
index 0000000..2a92f58
--- /dev/null
+++ b/static/js/tracker/externs.js
@@ -0,0 +1,115 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+
+// Defined in framework/js:core_scripts
+var _hideID;
+var _showID;
+var _hideEl;
+var _showEl;
+var _showInstead;
+var _toggleHidden;
+
+var _selectAllIssues;
+var _selectNoneIssues;
+
+var _toggleRows;
+var _toggleColumn;
+var _toggleColumnUpdate;
+var _addGroupBy;
+var _addcol;
+var _checkRangeSelect;
+var _setRowLinks;
+var _makeIssueLink;
+
+var _onload;
+
+var _handleListActions;
+var _handleDetailActions;
+
+var _loadStatusSelect;
+var _fetchOptions;
+var _setACOptions;
+var _openIssueUpdateForm;
+var _addAttachmentFields;
+var _ignoreWidgetIfOpIsClear;
+
+var _formatContextQueryArgs;
+var _ctxArgs;
+var _ctxCan;
+var _ctxQuery;
+var _ctxSortspec;
+var _ctxGroupBy;
+var _ctxDefaultColspec;
+var _ctxStart;
+var _ctxNum;
+var _ctxResultsPerPage;
+
+var _filterTo;
+var _sortUp;
+var _sortDown;
+
+var _closeAllPopups;
+var _closeSubmenus;
+var _showRight;
+var _showBelow;
+var _highlightRow;
+var _highlightRowCallback;
+var _allColumnNames;
+
+var _setFieldIDs;
+var _selectTemplate;
+var _saveTemplate;
+var _newTemplate;
+var _deleteTemplate;
+var _switchTemplate;
+var _templateNames;
+
+var _confirmNovelStatus;
+var _confirmNovelLabel;
+var _lfidprefix;
+var _allOrigLabels;
+var _vallab;
+var _exposeExistingLabelFields;
+var _confirmDiscardEntry;
+var _confirmDiscardUpdate;
+var _checkPlusOne;
+var _checkUnrestrict;
+
+var _clearOnFirstEvent;
+var _forceProperTableWidth;
+
+var _acof;
+var _acmo;
+var _acse;
+var _acstore;
+var _acreg;
+var _accomp;
+var _acrob;
+
+var _d;
+
+var _getColspec;
+
+var issueRefs;
+
+var kibbles;
+var _setupKibblesOnEntryPage;
+var _setupKibblesOnListPage;
+var _setupKibblesOnDetailPage;
+
+var CS_env;
+
+var _checkFieldNameOnServer;
+var _checkLeafName;
+
+var _addMultiFieldValueWidget;
+var _removeMultiFieldValueWidget;
+var console;
+var _trimCommas;
+
+var _initDragAndDrop;
diff --git a/static/js/tracker/render-hotlist-table.js b/static/js/tracker/render-hotlist-table.js
new file mode 100644
index 0000000..5004296
--- /dev/null
+++ b/static/js/tracker/render-hotlist-table.js
@@ -0,0 +1,436 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions used in rendering a hotlistissues table
+ */
+
+
+/**
+ * Helper function to set several attributes of an element at once.
+ * @param {Element} el element that is getting the attributes
+ * @param {dict} attrs Dictionary of {attrName: attrValue, ..}
+ */
+function setAttributes(el, attrs) {
+ for (let key in attrs) {
+ el.setAttribute(key, attrs[key]);
+ }
+}
+
+// TODO(jojwang): readOnly is currently empty string, figure out what it should be
+// ('True'/'False' 'yes'/'no'?).
+
+/**
+ * Helper function for creating a <td> element that contains the widgets of the row.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {} readOnly.
+ * @param {boolean} userLoggedIn is the current user logged in.
+ * @return an element containing the widget elements
+ */
+function createWidgets(tableRow, readOnly, userLoggedIn) {
+ let widgets = document.createElement('td');
+ widgets.setAttribute('class', 'rowwidgets nowrap');
+
+ let gripper = document.createElement('i');
+ gripper.setAttribute('class', 'material-icons gripper');
+ gripper.setAttribute('title', 'Drag issue');
+ gripper.textContent = 'drag_indicator';
+ widgets.appendChild(gripper);
+
+ if (!readOnly) {
+ if (userLoggedIn) {
+ // TODO(jojwang): for bulk edit, only show a checkbox next to an issue that
+ // the user has permission to edit.
+ let checkbox = document.createElement('input');
+ setAttributes(checkbox, {'class': 'checkRangeSelect',
+ 'id': 'cb_' + tableRow['issueRef'],
+ 'type': 'checkbox'});
+ widgets.appendChild(checkbox);
+ widgets.appendChild(document.createTextNode(' '));
+
+ let star = document.createElement('a');
+ let starColor = tableRow['isStarred'] ? 'cornflowerblue' : 'gray';
+ let starred = tableRow['isStarred'] ? 'Un-s' : 'S';
+ setAttributes(star, {'class': 'star',
+ 'id': 'star-' + tableRow['projectName'] + tableRow['localID'],
+ 'style': 'color:' + starColor,
+ 'title': starred + 'tar this issue',
+ 'data-project-name': tableRow['projectName'],
+ 'data-local-id': tableRow['localID']});
+ star.textContent = (tableRow['isStarred'] ? '\u2605' : '\u2606');
+ widgets.appendChild(star);
+ }
+ }
+ return widgets;
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an ID cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {boolean} isCrossProject are issues in the table from more than one project.
+*/
+function createIDCell(td, tableRow, isCrossProject) {
+ td.classList.add('id');
+ let aLink = document.createElement('a');
+ aLink.setAttribute('href', tableRow['issueCleanURL']);
+ aLink.setAttribute('class', 'computehref');
+ let aLinkContent = (isCrossProject ? (tableRow['projectName'] + ':') : '' ) + tableRow['localID'];
+ aLink.textContent = aLinkContent;
+ td.appendChild(aLink);
+}
+
+function createProjectCell(td, tableRow) {
+ td.classList.add('project');
+ let aLink = document.createElement('a');
+ aLink.setAttribute('href', tableRow['projectURL']);
+ aLink.textContent = tableRow['projectName'];
+ td.appendChild(aLink);
+}
+
+function createEditableNoteCell(td, cell, projectName, localID, hotlistID) {
+ let textBox = document.createElement('textarea');
+ setAttributes(textBox, {
+ 'id': `itemnote_${projectName}_${localID}`,
+ 'placeholder': '---',
+ 'class': 'itemnote rowwidgets',
+ 'projectname': projectName,
+ 'localid': localID,
+ 'style': 'height:15px',
+ });
+ if (cell['values'].length > 0) {
+ textBox.value = cell['values'][0]['item'];
+ }
+ textBox.addEventListener('blur', function(e) {
+ saveNote(e.target, hotlistID);
+ });
+ debouncedKeyHandler = debounce(function(e) {
+ saveNote(e.target, hotlistID);
+ });
+ textBox.addEventListener('keyup', debouncedKeyHandler, false);
+ td.appendChild(textBox);
+}
+
+function enter_detector(e) {
+ if (e.which==13||e.keyCode==13) {
+ this.blur();
+ }
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an Summary cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} cell dictionary {'values': [], .. } of relevant cell info.
+ * @param {string=} projectName The name of the project the summary references.
+*/
+function createSummaryCell(td, cell, projectName) {
+ // TODO(jojwang): detect when links are present and make clicking on cell go
+ // to link, not issue details page
+ td.setAttribute('style', 'width:100%');
+ fillValues(td, cell['values']);
+ fillNonColumnLabels(td, cell['nonColLabels'], projectName);
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an Attribute or Unfilterable cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} cell dictionary {'type': 'Summary', .. } of relevant cell info.
+*/
+function createAttrAndUnfiltCell(td, cell) {
+ if (cell['noWrap'] == 'yes') {
+ td.className += ' nowrapspan';
+ }
+ if (cell['align']) {
+ td.setAttribute('align', cell['align']);
+ }
+ fillValues(td, cell['values']);
+}
+
+function createUrlCell(td, cell) {
+ td.classList.add('url');
+ cell.values.forEach((value) => {
+ let aLink = document.createElement('a');
+ aLink.href = value['item'];
+ aLink.target = '_blank';
+ aLink.rel = 'nofollow';
+ aLink.textContent = value['item'];
+ aLink.classList.add('fieldvalue_url');
+ td.appendChild(aLink);
+ });
+}
+
+function createIssuesCell(td, cell) {
+ td.classList.add('url');
+ if (cell.values.length > 0) {
+ cell.values.forEach( function(value, index, array) {
+ const span = document.createElement('span');
+ if (value['isDerived']) {
+ span.className = 'derived';
+ }
+ const a = document.createElement('a');
+ a.href = value['href'];
+ a.rel = 'nofollow"';
+ if (value['title']) {
+ a.title = value['title'];
+ }
+ if (value['closed']) {
+ a.style.textDecoration = 'line-through';
+ }
+ a.textContent = value['id'];
+ span.appendChild(a);
+ td.appendChild(span);
+ if (index != array.length-1) {
+ td.appendChild(document.createTextNode(', '));
+ }
+ });
+ } else {
+ td.textContent = '---';
+ }
+}
+
+/**
+ * Helper function to fill a td element with a cell's non-column labels.
+ * @param {Element} td element to be added to current row in table.
+ * @param {list} labels list of dictionaries with relevant (key, value) for
+ * each label
+ * @param {string=} projectName The name of the project the labels reference.
+ */
+function fillNonColumnLabels(td, labels, projectName) {
+ labels.forEach( function(label) {
+ const aLabel = document.createElement('a');
+ setAttributes(aLabel,
+ {
+ 'class': 'label',
+ 'href': `/p/${projectName}/issues/list?q=label:${label['value']}`,
+ });
+ if (label['isDerived']) {
+ const i = document.createElement('i');
+ i.textContent = label['value'];
+ aLabel.appendChild(i);
+ } else {
+ aLabel.textContent = label['value'];
+ }
+ td.appendChild(document.createTextNode(' '));
+ td.appendChild(aLabel);
+ });
+}
+
+
+/**
+ * Helper function to fill a td element with a cell's value(s).
+ * @param {Element} td element to be added to current row in table.
+ * @param {list} values list of dictionaries with relevant (key, value) for each value
+ */
+function fillValues(td, values) {
+ if (values.length > 0) {
+ values.forEach( function(value, index, array) {
+ let span = document.createElement('span');
+ if (value['isDerived']) {
+ span.className = 'derived';
+ }
+ span.textContent = value['item'];
+ td.appendChild(span);
+ if (index != array.length-1) {
+ td.appendChild(document.createTextNode(', '));
+ }
+ });
+ } else {
+ td.textContent = '---';
+ }
+}
+
+
+/**
+ * Helper function to create a table row.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user viewing the page.
+ */
+function renderHotlistRow(tableRow, pageSettings) {
+ let tr = document.createElement('tr');
+ if (pageSettings['cursor'] == tableRow['issueRef']) {
+ tr.setAttribute('class', 'ifOpened hoverTarget cursor_on drag_item');
+ } else {
+ tr.setAttribute('class', 'ifOpened hoverTarget cursor_off drag_item');
+ }
+
+ setAttributes(tr, {'data-idx': tableRow['idx'], 'data-id': tableRow['issueID'], 'issue-context-url': tableRow['issueContextURL']});
+ widgets = createWidgets(tableRow, pageSettings['readOnly'],
+ pageSettings['userLoggedIn']);
+ tr.appendChild(widgets);
+ tableRow['cells'].forEach(function(cell) {
+ let td = document.createElement('td');
+ td.setAttribute('class', 'col_' + cell['colIndex']);
+ if (cell['type'] == 'ID') {
+ createIDCell(td, tableRow, (pageSettings['isCrossProject'] == 'True'));
+ } else if (cell['type'] == 'summary') {
+ createSummaryCell(td, cell, tableRow['projectName']);
+ } else if (cell['type'] == 'note') {
+ if (pageSettings['ownerPerm'] || pageSettings['editorPerm']) {
+ createEditableNoteCell(
+ td, cell, tableRow['projectName'], tableRow['localID'],
+ pageSettings['hotlistID']);
+ } else {
+ createSummaryCell(td, cell, tableRow['projectName']);
+ }
+ } else if (cell['type'] == 'project') {
+ createProjectCell(td, tableRow);
+ } else if (cell['type'] == 'url') {
+ createUrlCell(td, cell);
+ } else if (cell['type'] == 'issues') {
+ createIssuesCell(td, cell);
+ } else {
+ createAttrAndUnfiltCell(td, cell);
+ }
+ tr.appendChild(td);
+ });
+ let directLinkURL = tableRow['issueCleanURL'];
+ let directLink = document.createElement('a');
+ directLink.setAttribute('class', 'directlink material-icons');
+ directLink.setAttribute('href', directLinkURL);
+ directLink.textContent = 'link'; // Renders as a link icon.
+ let lastCol = document.createElement('td');
+ lastCol.appendChild(directLink);
+ tr.appendChild(lastCol);
+ return tr;
+}
+
+
+/**
+ * Helper function to create the group header row
+ * @param {dict} group dict of relevant values for the current group
+ * @return a <tr> element to be added to the current <tbody>
+ */
+function renderGroupRow(group) {
+ let tr = document.createElement('tr');
+ tr.setAttribute('class', 'group_row');
+ let td = document.createElement('td');
+ setAttributes(td, {'colspan': '100', 'class': 'toggleHidden'});
+ let whenClosedImg = document.createElement('img');
+ setAttributes(whenClosedImg, {'class': 'ifClosed', 'src': '/static/images/plus.gif'});
+ td.appendChild(whenClosedImg);
+ let whenOpenImg = document.createElement('img');
+ setAttributes(whenOpenImg, {'class': 'ifOpened', 'src': '/static/images/minus.gif'});
+ td.appendChild(whenOpenImg);
+ tr.appendChild(td);
+
+ div = document.createElement('div');
+ div.textContent += group['rowsInGroup'];
+
+ div.textContent += (group['rowsInGroup'] == '1' ? ' issue:': ' issues:');
+
+ group['cells'].forEach(function(cell) {
+ let hasValue = false;
+ cell['values'].forEach(function(value) {
+ if (value['item'] !== 'None') {
+ hasValue = true;
+ }
+ });
+ if (hasValue) {
+ cell.values.forEach(function(value) {
+ div.textContent += (' ' + cell['groupName'] + '=' + value['item']);
+ });
+ } else {
+ div.textContent += (' -has:' + cell['groupName']);
+ }
+ });
+ td.appendChild(div);
+ return tr;
+}
+
+
+/**
+ * Builds the body of a hotlistissues table.
+ * @param {dict} tableData dict of relevant values from 'table_data'
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user viewing the page.
+ */
+function renderHotlistTable(tableData, pageSettings) {
+ let tbody;
+ let table = $('resultstable');
+
+ // TODO(jojwang): this would not work if grouping did not require a page refresh
+ // that wiped the table of all its children. This should be redone to be more
+ // robust.
+ // This loop only does anything when reranking is enabled.
+ for (i=0; i < table.childNodes.length; i++) {
+ if (table.childNodes[i].tagName == 'TBODY') {
+ table.removeChild(table.childNodes[i]);
+ }
+ }
+
+ tableData.forEach(function(tableRow) {
+ if (tableRow['group'] !== 'no') {
+ // add current tbody to table, need a new tbody with group row
+ if (typeof tbody !== 'undefined') {
+ table.appendChild(tbody);
+ }
+ tbody = document.createElement('tbody');
+ tbody.setAttribute('class', 'opened');
+ tbody.appendChild(renderGroupRow(tableRow['group']));
+ }
+ if (typeof tbody == 'undefined') {
+ tbody = document.createElement('tbody');
+ }
+ tbody.appendChild(renderHotlistRow(tableRow, pageSettings));
+ });
+ tbody.appendChild(document.createElement('tr'));
+ table.appendChild(tbody);
+
+ let stars = document.getElementsByClassName('star');
+ for (var i = 0; i < stars.length; ++i) {
+ let star = stars[i];
+ star.addEventListener('click', function(event) {
+ let projectName = event.target.getAttribute('data-project-name');
+ let localID = event.target.getAttribute('data-local-id');
+ _TKR_toggleStar(event.target, projectName, localID, null, null, null);
+ });
+ }
+}
+
+
+/**
+ * Activates the drag and drop functionality of the hotlistissues table.
+ * @param {dict} tableData dict of relevant values from the 'table_data' of
+ * hotlistissues servlet. This is used when a drag and drop motion does not
+ * result in any changes in the ordering of the issues.
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user
+ * viewing the page.
+ * @param {str} hotlistID the number ID of the current hotlist
+*/
+function activateDragDrop(tableData, pageSettings, hotlistID) {
+ function onHotlistRerank(srcID, targetID, position) {
+ let data = {
+ target_id: targetID,
+ moved_ids: srcID,
+ split_above: position == 'above',
+ colspec: pageSettings['colSpec'],
+ can: pageSettings['can'],
+ };
+ CS_doPost(hotlistID + '/rerank.do', onHotlistResponse, data);
+ }
+
+ function onHotlistResponse(event) {
+ let xhr = event.target;
+ if (xhr.readyState != 4) {
+ return;
+ }
+ if (xhr.status != 200) {
+ window.console.error('200 page error');
+ // TODO(jojwang): fill this in more
+ return;
+ }
+ let response = CS_parseJSON(xhr);
+ renderHotlistTable(
+ (response['table_data'] == '' ? tableData : response['table_data']),
+ pageSettings);
+ // TODO(jojwang): pass pagination state to server
+ _initDragAndDrop($('resultstable'), onHotlistRerank, true);
+ }
+ _initDragAndDrop($('resultstable'), onHotlistRerank, true);
+}
diff --git a/static/js/tracker/tracker-ac.js b/static/js/tracker/tracker-ac.js
new file mode 100644
index 0000000..4d98ac1
--- /dev/null
+++ b/static/js/tracker/tracker-ac.js
@@ -0,0 +1,1285 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+/**
+ * This file contains the autocomplete configuration logic that is
+ * specific to the issue fields of Monorail. It depends on ac.js, our
+ * modified version of the autocomplete library.
+ */
+
+/**
+ * This is an autocomplete store that holds the hotlists of the current user.
+ */
+let TKR_hotlistsStore;
+
+/**
+ * This is an autocomplete store that holds well-known issue label
+ * values for the current project.
+ */
+let TKR_labelStore;
+
+/**
+ * Like TKR_labelStore but stores only label prefixes.
+ */
+let TKR_labelPrefixStore;
+
+/**
+ * Like TKR_labelStore but adds a trailing comma instead of replacing.
+ */
+let TKR_labelMultiStore;
+
+/**
+ * This is an autocomplete store that holds issue components.
+ */
+let TKR_componentStore;
+
+/**
+ * Like TKR_componentStore but adds a trailing comma instead of replacing.
+ */
+let TKR_componentListStore;
+
+/**
+ * This is an autocomplete store that holds many different kinds of
+ * items that can be shown in the artifact search autocomplete.
+ */
+let TKR_searchStore;
+
+/**
+ * This is similar to TKR_searchStore, but does not include any suggestions
+ * to use the "me" keyword. Using "me" is not a good idea for project canned
+ * queries and filter rules.
+ */
+let TKR_projectQueryStore;
+
+/**
+ * This is an autocomplete store that holds items for the quick edit
+ * autocomplete.
+ */
+// TODO(jrobbins): add options for fields and components.
+let TKR_quickEditStore;
+
+/**
+ * This is a list of label prefixes that each issue should only use once.
+ * E.g., each issue should only have one Priority-* label. We do not prevent
+ * the user from using multiple such labels, we just warn the user before
+ * they submit.
+ */
+let TKR_exclPrefixes = [];
+
+/**
+ * This is an autocomplete store that holds custom permission names that
+ * have already been used in this project.
+ */
+let TKR_customPermissionsStore;
+
+
+/**
+ * This is an autocomplete store that holds well-known issue status
+ * values for the current project.
+ */
+let TKR_statusStore;
+
+
+/**
+ * This is an autocomplete store that holds the usernames of all the
+ * members of the current project. This is used for autocomplete in
+ * the cc-list of an issue, where many user names can entered with
+ * commas between them.
+ */
+let TKR_memberListStore;
+
+
+/**
+ * This is an autocomplete store that holds the projects that the current
+ * user is contributor/member/owner of.
+ */
+let TKR_projectStore;
+
+/**
+ * This is an autocomplete store that holds the usernames of possible
+ * issue owners in the current project. The list of possible issue
+ * owners is the same as the list of project members, but the behavior
+ * of this autocompete store is different because the issue owner text
+ * field can only accept one value.
+ */
+let TKR_ownerStore;
+
+
+/**
+ * This is an autocomplete store that holds any list of string for choices.
+ */
+let TKR_autoCompleteStore;
+
+
+/**
+ * An array of autocomplete stores used for user-type custom fields.
+ */
+const TKR_userAutocompleteStores = [];
+
+
+/**
+ * This boolean controls whether odd-ball status and labels are treated as
+ * a warning or an error. Normally, it is False.
+ */
+// TODO(jrobbins): split this into one option for statuses and one for labels.
+let TKR_restrict_to_known;
+
+/**
+ * This substitute function should be used for multi-valued autocomplete fields
+ * that are delimited by commas. When we insert an autocomplete value, replace
+ * an entire search term. Add a comma and a space after it if it is a complete
+ * search term.
+ */
+function TKR_acSubstituteWithComma(inputValue, caret, completable, completion) {
+ let nextTerm = caret;
+
+ // Subtract one in case the cursor is at the end of the input, before a comma.
+ let prevTerm = caret - 1;
+ while (nextTerm < inputValue.length - 1 && inputValue.charAt(nextTerm) !== ',') {
+ nextTerm++;
+ }
+ // Set this at the position after the found comma.
+ nextTerm++;
+
+ while (prevTerm > 0 && ![',', ' '].includes(inputValue.charAt(prevTerm))) {
+ prevTerm--;
+ }
+ if (prevTerm > 0) {
+ // Set this boundary after the found space/comma if it's not the beginning
+ // of the field.
+ prevTerm++;
+ }
+
+ return inputValue.substring(0, prevTerm) +
+ completion.value + ', ' + inputValue.substring(nextTerm);
+}
+
+/**
+ * When the prefix starts with '*', return the complete set of all
+ * possible completions.
+ * @param {string} prefix If this starts with '*', return all possible
+ * completions. Otherwise return null.
+ * @param {Array} labelDefs The array of label names and docstrings.
+ * @return Array of new _AC_Completions for each possible completion, or null.
+ */
+function TKR_fullComplete(prefix, labelDefs) {
+ if (!prefix.startsWith('*')) return null;
+ const out = [];
+ for (let i = 0; i < labelDefs.length; i++) {
+ out.push(new _AC_Completion(labelDefs[i].name,
+ labelDefs[i].name,
+ labelDefs[i].doc));
+ }
+ return out;
+}
+
+
+/**
+ * Constucts a list of all completions for both open and closed
+ * statuses, with a header for each group.
+ * @param {string} prefix If starts with '*', return all possible completions,
+ * else return null.
+ * @param {Array} openStatusDefs The array of open status values and
+ * docstrings.
+ * @param {Array} closedStatusDefs The array of closed status values
+ * and docstrings.
+ * @return Array of new _AC_Completions for each possible completion, or null.
+ */
+function TKR_openClosedComplete(prefix, openStatusDefs, closedStatusDefs) {
+ if (!prefix.startsWith('*')) return null;
+ const out = [];
+ out.push({heading: 'Open Statuses:'}); // TODO: i18n
+ for (var i = 0; i < openStatusDefs.length; i++) {
+ out.push(new _AC_Completion(openStatusDefs[i].name,
+ openStatusDefs[i].name,
+ openStatusDefs[i].doc));
+ }
+ out.push({heading: 'Closed Statuses:'}); // TODO: i18n
+ for (var i = 0; i < closedStatusDefs.length; i++) {
+ out.push(new _AC_Completion(closedStatusDefs[i].name,
+ closedStatusDefs[i].name,
+ closedStatusDefs[i].doc));
+ }
+ return out;
+}
+
+
+function TKR_setUpHotlistsStore(hotlists) {
+ const docdict = {};
+ const ref_strs = [];
+
+ for (let i = 0; i < hotlists.length; i++) {
+ ref_strs.push(hotlists[i]['ref_str']);
+ docdict[hotlists[i]['ref_str']] = hotlists[i]['summary'];
+ }
+
+ TKR_hotlistsStore = new _AC_SimpleStore(ref_strs, docdict);
+ TKR_hotlistsStore.substitute = TKR_acSubstituteWithComma;
+}
+
+
+/**
+ * An array of definitions of all well-known issue statuses. Each
+ * definition has the name of the status value, and a docstring that
+ * describes its meaning.
+ */
+let TKR_statusWords = [];
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known issue
+ * status values. The store has some DIT-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} openStatusDefs An array of definitions of the
+ * well-known open status values. Each definition has a name and
+ * docstring.
+ * @param {Array} closedStatusDefs An array of definitions of the
+ * well-known closed status values. Each definition has a name and
+ * docstring.
+ */
+function TKR_setUpStatusStore(openStatusDefs, closedStatusDefs) {
+ const docdict = {};
+ TKR_statusWords = [];
+ for (var i = 0; i < openStatusDefs.length; i++) {
+ var status = openStatusDefs[i];
+ TKR_statusWords.push(status.name);
+ docdict[status.name] = status.doc;
+ }
+ for (var i = 0; i < closedStatusDefs.length; i++) {
+ var status = closedStatusDefs[i];
+ TKR_statusWords.push(status.name);
+ docdict[status.name] = status.doc;
+ }
+
+ TKR_statusStore = new _AC_SimpleStore(TKR_statusWords, docdict);
+
+ TKR_statusStore.commaCompletes = false;
+
+ TKR_statusStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ TKR_statusStore.completable = function(inputValue, cursor) {
+ if (!ac_everTyped) return '*status';
+ return inputValue;
+ };
+
+ TKR_statusStore.completions = function(prefix, tofilter) {
+ const fullList = TKR_openClosedComplete(prefix,
+ openStatusDefs,
+ closedStatusDefs);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+}
+
+
+/**
+ * Simple function to add a given item to the list of items used to construct
+ * an "autocomplete store", and also update the docstring that describes
+ * that item. They are stored separately for backward compatability with
+ * autocomplete store logic that preceeded the introduction of descriptions.
+ */
+function TKR_addACItem(items, docDict, item, docStr) {
+ items.push(item);
+ docDict[item] = docStr;
+}
+
+/**
+ * Adds a group of three items related to a date field.
+ */
+function TKR_addACDateItems(items, docDict, fieldName, humanReadable) {
+ const today = new Date();
+ const todayStr = (today.getFullYear() + '-' + (today.getMonth() + 1) + '-' +
+ today.getDate());
+ TKR_addACItem(items, docDict, fieldName + '>today-1',
+ humanReadable + ' within the last N days');
+ TKR_addACItem(items, docDict, fieldName + '>' + todayStr,
+ humanReadable + ' after the specified date');
+ TKR_addACItem(items, docDict, fieldName + '<today-1',
+ humanReadable + ' more than N days ago');
+}
+
+/**
+ * Add several autocomplete items to a word list that will be used to construct
+ * an autocomplete store. Also, keep track of description strings for each
+ * item. A search operator is prepended to the name of each item. The opt_old
+ * and opt_new parameters are used to transform Key-Value labels into Key=Value
+ * search terms.
+ */
+function TKR_addACItemList(
+ items, docDict, searchOp, acDefs, opt_old, opt_new) {
+ let item;
+ for (let i = 0; i < acDefs.length; i++) {
+ const nameAndDoc = acDefs[i];
+ item = searchOp + nameAndDoc.name;
+ if (opt_old) {
+ // Preserve any leading minus-sign.
+ item = item.slice(0, 1) + item.slice(1).replace(opt_old, opt_new);
+ }
+ TKR_addACItem(items, docDict, item, nameAndDoc.doc);
+ }
+}
+
+
+/**
+ * Use information from an options feed to populate the artifact search
+ * autocomplete menu. The order of sections is: custom fields, labels,
+ * components, people, status, special, dates. Within each section,
+ * options are ordered semantically where possible, or alphabetically
+ * if there is no semantic ordering. Negated options all come after
+ * all normal options.
+ */
+function TKR_setUpSearchStore(
+ labelDefs, memberDefs, openDefs, closedDefs, componentDefs, fieldDefs,
+ indMemberDefs) {
+ let searchWords = [];
+ const searchWordsNeg = [];
+ const docDict = {};
+
+ // Treat Key-Value and OneWord labels separately.
+ const keyValueLabelDefs = [];
+ const oneWordLabelDefs = [];
+ for (var i = 0; i < labelDefs.length; i++) {
+ const nameAndDoc = labelDefs[i];
+ if (nameAndDoc.name.indexOf('-') == -1) {
+ oneWordLabelDefs.push(nameAndDoc);
+ } else {
+ keyValueLabelDefs.push(nameAndDoc);
+ }
+ }
+
+ // Autocomplete for custom fields.
+ for (i = 0; i < fieldDefs.length; i++) {
+ const fieldName = fieldDefs[i]['field_name'];
+ const fieldType = fieldDefs[i]['field_type'];
+ if (fieldType == 'ENUM_TYPE') {
+ const choices = fieldDefs[i]['choices'];
+ TKR_addACItemList(searchWords, docDict, fieldName + '=', choices);
+ TKR_addACItemList(searchWordsNeg, docDict, '-' + fieldName + '=', choices);
+ } else if (fieldType == 'STR_TYPE') {
+ TKR_addACItem(searchWords, docDict, fieldName + ':',
+ fieldDefs[i]['docstring']);
+ } else if (fieldType == 'DATE_TYPE') {
+ TKR_addACItem(searchWords, docDict, fieldName + ':',
+ fieldDefs[i]['docstring']);
+ TKR_addACDateItems(searchWords, docDict, fieldName, fieldName);
+ } else {
+ TKR_addACItem(searchWords, docDict, fieldName + '=',
+ fieldDefs[i]['docstring']);
+ }
+ TKR_addACItem(searchWords, docDict, 'has:' + fieldName,
+ 'Issues with any ' + fieldName + ' value');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:' + fieldName,
+ 'Issues with no ' + fieldName + ' value');
+ }
+
+ // Add suggestions with "me" first, because otherwise they may be impossible
+ // to reach in a project that has a lot of members with emails starting with
+ // "me".
+ if (CS_env['loggedInUserEmail']) {
+ TKR_addACItem(searchWords, docDict, 'owner:me', 'Issues owned by me');
+ TKR_addACItem(searchWordsNeg, docDict, '-owner:me', 'Issues not owned by me');
+ TKR_addACItem(searchWords, docDict, 'cc:me', 'Issues that CC me');
+ TKR_addACItem(searchWordsNeg, docDict, '-cc:me', 'Issues that don\'t CC me');
+ TKR_addACItem(searchWords, docDict, 'reporter:me', 'Issues I reported');
+ TKR_addACItem(searchWordsNeg, docDict, '-reporter:me', 'Issues reported by others');
+ TKR_addACItem(searchWords, docDict, 'commentby:me',
+ 'Issues that I commented on');
+ TKR_addACItem(searchWordsNeg, docDict, '-commentby:me',
+ 'Issues that I didn\'t comment on');
+ }
+
+ TKR_addACItemList(searchWords, docDict, '', keyValueLabelDefs, '-', '=');
+ TKR_addACItemList(searchWordsNeg, docDict, '-', keyValueLabelDefs, '-', '=');
+ TKR_addACItemList(searchWords, docDict, 'label:', oneWordLabelDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-label:', oneWordLabelDefs);
+
+ TKR_addACItemList(searchWords, docDict, 'component:', componentDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-component:', componentDefs);
+ TKR_addACItem(searchWords, docDict, 'has:component',
+ 'Issues with any components specified');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:component',
+ 'Issues with no components specified');
+
+ TKR_addACItemList(searchWords, docDict, 'owner:', indMemberDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-owner:', indMemberDefs);
+ TKR_addACItemList(searchWords, docDict, 'cc:', memberDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-cc:', memberDefs);
+ TKR_addACItem(searchWords, docDict, 'has:cc',
+ 'Issues with any cc\'d users');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:cc',
+ 'Issues with no cc\'d users');
+ TKR_addACItemList(searchWords, docDict, 'reporter:', memberDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-reporter:', memberDefs);
+ TKR_addACItemList(searchWords, docDict, 'status:', openDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-status:', openDefs);
+ TKR_addACItemList(searchWords, docDict, 'status:', closedDefs);
+ TKR_addACItemList(searchWordsNeg, docDict, '-status:', closedDefs);
+ TKR_addACItem(searchWords, docDict, 'has:status',
+ 'Issues with any status');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:status',
+ 'Issues with no status');
+
+ TKR_addACItem(searchWords, docDict, 'is:blocked',
+ 'Issues that are blocked');
+ TKR_addACItem(searchWordsNeg, docDict, '-is:blocked',
+ 'Issues that are not blocked');
+ TKR_addACItem(searchWords, docDict, 'has:blockedon',
+ 'Issues that are blocked');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:blockedon',
+ 'Issues that are not blocked');
+ TKR_addACItem(searchWords, docDict, 'has:blocking',
+ 'Issues that are blocking other issues');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:blocking',
+ 'Issues that are not blocking other issues');
+ TKR_addACItem(searchWords, docDict, 'has:mergedinto',
+ 'Issues that were merged into other issues');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:mergedinto',
+ 'Issues that were not merged into other issues');
+
+ TKR_addACItem(searchWords, docDict, 'is:starred',
+ 'Starred by me');
+ TKR_addACItem(searchWordsNeg, docDict, '-is:starred',
+ 'Not starred by me');
+ TKR_addACItem(searchWords, docDict, 'stars>10',
+ 'More than 10 stars');
+ TKR_addACItem(searchWords, docDict, 'stars>100',
+ 'More than 100 stars');
+ TKR_addACItem(searchWords, docDict, 'summary:',
+ 'Search within the summary field');
+
+ TKR_addACItemList(searchWords, docDict, 'commentby:', memberDefs);
+ TKR_addACItem(searchWords, docDict, 'attachment:',
+ 'Search within attachment names');
+ TKR_addACItem(searchWords, docDict, 'attachments>5',
+ 'Has more than 5 attachments');
+ TKR_addACItem(searchWords, docDict, 'is:open', 'Issues that are open');
+ TKR_addACItem(searchWordsNeg, docDict, '-is:open', 'Issues that are closed');
+ TKR_addACItem(searchWords, docDict, 'has:owner',
+ 'Issues with some owner');
+ TKR_addACItem(searchWordsNeg, docDict, '-has:owner',
+ 'Issues with no owner');
+ TKR_addACItem(searchWords, docDict, 'has:attachments',
+ 'Issues with some attachments');
+ TKR_addACItem(searchWords, docDict, 'id:1,2,3',
+ 'Match only the specified issues');
+ TKR_addACItem(searchWords, docDict, 'id<100000',
+ 'Issues with IDs under 100,000');
+ TKR_addACItem(searchWords, docDict, 'blockedon:1',
+ 'Blocked on the specified issues');
+ TKR_addACItem(searchWords, docDict, 'blocking:1',
+ 'Blocking the specified issues');
+ TKR_addACItem(searchWords, docDict, 'mergedinto:1',
+ 'Merged into the specified issues');
+ TKR_addACItem(searchWords, docDict, 'is:ownerbouncing',
+ 'Issues with owners we cannot contact');
+ TKR_addACItem(searchWords, docDict, 'is:spam', 'Issues classified as spam');
+ // We do not suggest -is:spam because it is implicit.
+
+ TKR_addACDateItems(searchWords, docDict, 'opened', 'Opened');
+ TKR_addACDateItems(searchWords, docDict, 'modified', 'Modified');
+ TKR_addACDateItems(searchWords, docDict, 'closed', 'Closed');
+ TKR_addACDateItems(searchWords, docDict, 'ownermodified', 'Owner field modified');
+ TKR_addACDateItems(searchWords, docDict, 'ownerlastvisit', 'Owner last visit');
+ TKR_addACDateItems(searchWords, docDict, 'statusmodified', 'Status field modified');
+ TKR_addACDateItems(
+ searchWords, docDict, 'componentmodified', 'Component field modified');
+
+ TKR_projectQueryStore = new _AC_SimpleStore(searchWords, docDict);
+
+ searchWords = searchWords.concat(searchWordsNeg);
+
+ TKR_searchStore = new _AC_SimpleStore(searchWords, docDict);
+
+ // When we insert an autocomplete value, replace an entire search term.
+ // Add just a space after it (not a comma) if it is a complete search term,
+ // or leave the caret immediately after the completion if we are just helping
+ // the user with the search operator.
+ TKR_searchStore.substitute =
+ function(inputValue, caret, completable, completion) {
+ let nextTerm = caret;
+ while (inputValue.charAt(nextTerm) != ' ' &&
+ nextTerm < inputValue.length) {
+ nextTerm++;
+ }
+ while (inputValue.charAt(nextTerm) == ' ' &&
+ nextTerm < inputValue.length) {
+ nextTerm++;
+ }
+ return inputValue.substring(0, caret - completable.length) +
+ completion.value + ' ' + inputValue.substring(nextTerm);
+ };
+ TKR_searchStore.autoselectFirstRow =
+ function() {
+ return false;
+ };
+
+ TKR_projectQueryStore.substitute = TKR_searchStore.substitute;
+ TKR_projectQueryStore.autoselectFirstRow = TKR_searchStore.autoselectFirstRow;
+}
+
+
+/**
+ * Use information from an options feed to populate the issue quick edit
+ * autocomplete menu.
+ */
+function TKR_setUpQuickEditStore(
+ labelDefs, memberDefs, openDefs, closedDefs, indMemberDefs) {
+ const qeWords = [];
+ const docDict = {};
+
+ // Treat Key-Value and OneWord labels separately.
+ const keyValueLabelDefs = [];
+ const oneWordLabelDefs = [];
+ for (let i = 0; i < labelDefs.length; i++) {
+ const nameAndDoc = labelDefs[i];
+ if (nameAndDoc.name.indexOf('-') == -1) {
+ oneWordLabelDefs.push(nameAndDoc);
+ } else {
+ keyValueLabelDefs.push(nameAndDoc);
+ }
+ }
+ TKR_addACItemList(qeWords, docDict, '', keyValueLabelDefs, '-', '=');
+ TKR_addACItemList(qeWords, docDict, '-', keyValueLabelDefs, '-', '=');
+ TKR_addACItemList(qeWords, docDict, '', oneWordLabelDefs);
+ TKR_addACItemList(qeWords, docDict, '-', oneWordLabelDefs);
+
+ TKR_addACItem(qeWords, docDict, 'owner=me', 'Make me the owner');
+ TKR_addACItem(qeWords, docDict, 'owner=----', 'Clear the owner field');
+ TKR_addACItem(qeWords, docDict, 'cc=me', 'CC me on this issue');
+ TKR_addACItem(qeWords, docDict, 'cc=-me', 'Remove me from CC list');
+ TKR_addACItemList(qeWords, docDict, 'owner=', indMemberDefs);
+ TKR_addACItemList(qeWords, docDict, 'cc=', memberDefs);
+ TKR_addACItemList(qeWords, docDict, 'cc=-', memberDefs);
+ TKR_addACItemList(qeWords, docDict, 'status=', openDefs);
+ TKR_addACItemList(qeWords, docDict, 'status=', closedDefs);
+ TKR_addACItem(qeWords, docDict, 'summary=""', 'Set the summary field');
+
+ TKR_quickEditStore = new _AC_SimpleStore(qeWords, docDict);
+
+ // When we insert an autocomplete value, replace an entire command part.
+ // Add just a space after it (not a comma) if it is a complete part,
+ // or leave the caret immediately after the completion if we are just helping
+ // the user with the command operator.
+ TKR_quickEditStore.substitute =
+ function(inputValue, caret, completable, completion) {
+ let nextTerm = caret;
+ while (inputValue.charAt(nextTerm) != ' ' &&
+ nextTerm < inputValue.length) {
+ nextTerm++;
+ }
+ while (inputValue.charAt(nextTerm) == ' ' &&
+ nextTerm < inputValue.length) {
+ nextTerm++;
+ }
+ return inputValue.substring(0, caret - completable.length) +
+ completion.value + ' ' + inputValue.substring(nextTerm);
+ };
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the project
+ * custom permissions.
+ * @param {Array} customPermissions An array of custom permission names.
+ */
+function TKR_setUpCustomPermissionsStore(customPermissions) {
+ customPermissions = customPermissions || [];
+ const permWords = ['View', 'EditIssue', 'AddIssueComment', 'DeleteIssue'];
+ const docdict = {
+ 'View': '', 'EditIssue': '', 'AddIssueComment': '', 'DeleteIssue': ''};
+ for (let i = 0; i < customPermissions.length; i++) {
+ permWords.push(customPermissions[i]);
+ docdict[customPermissions[i]] = '';
+ }
+
+ TKR_customPermissionsStore = new _AC_SimpleStore(permWords, docdict);
+
+ TKR_customPermissionsStore.commaCompletes = false;
+
+ TKR_customPermissionsStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known project
+ * member user names and real names. The store has some
+ * monorail-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} memberDefs an array of member objects.
+ * @param {Array} nonGroupMemberDefs an array of member objects who are not groups.
+ */
+function TKR_setUpMemberStore(memberDefs, nonGroupMemberDefs) {
+ const memberWords = [];
+ const indMemberWords = [];
+ const docdict = {};
+
+ memberDefs.forEach((memberDef) => {
+ memberWords.push(memberDef.name);
+ docdict[memberDef.name] = null;
+ });
+ nonGroupMemberDefs.forEach((memberDef) => {
+ indMemberWords.push(memberDef.name);
+ });
+
+ TKR_memberListStore = new _AC_SimpleStore(memberWords, docdict);
+
+ TKR_memberListStore.completions = function(prefix, tofilter) {
+ const fullList = TKR_fullComplete(prefix, memberDefs);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+
+ TKR_memberListStore.completable = function(inputValue, cursor) {
+ if (inputValue == '') return '*member';
+ return _AC_SimpleStore.prototype.completable.call(this, inputValue, cursor);
+ };
+
+ TKR_memberListStore.substitute = TKR_acSubstituteWithComma;
+
+ TKR_ownerStore = new _AC_SimpleStore(indMemberWords, docdict);
+
+ TKR_ownerStore.commaCompletes = false;
+
+ TKR_ownerStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ TKR_ownerStore.completions = function(prefix, tofilter) {
+ const fullList = TKR_fullComplete(prefix, nonGroupMemberDefs);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+
+ TKR_ownerStore.completable = function(inputValue, cursor) {
+ if (!ac_everTyped) return '*owner';
+ return inputValue;
+ };
+}
+
+
+/**
+ * Constuct one new autocomplete store for each user-valued custom
+ * field that has a needs_perm validation requirement, and thus a
+ * list of allowed user indexes.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} fieldDefs An array of field definitions, only some
+ * of which have a 'user_indexes' entry.
+ */
+function TKR_setUpUserAutocompleteStores(fieldDefs) {
+ fieldDefs.forEach((fieldDef) => {
+ if (fieldDef.qualifiedMembers) {
+ const us = makeOneUserAutocompleteStore(fieldDef);
+ TKR_userAutocompleteStores['custom_' + fieldDef['field_id']] = us;
+ }
+ });
+}
+
+function makeOneUserAutocompleteStore(fieldDef) {
+ const memberWords = [];
+ const docdict = {};
+ for (const member of fieldDef.qualifiedMembers) {
+ memberWords.push(member.name);
+ docdict[member.name] = member.doc;
+ }
+
+ const userStore = new _AC_SimpleStore(memberWords, docdict);
+ userStore.commaCompletes = false;
+
+ userStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ userStore.completions = function(prefix, tofilter) {
+ const fullList = TKR_fullComplete(prefix, fieldDef.qualifiedMembers);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+
+ userStore.completable = function(inputValue, cursor) {
+ if (!ac_everTyped) return '*custom';
+ return inputValue;
+ };
+
+ return userStore;
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the components.
+ * The store has some monorail-specific methods.
+ * @param {Array} componentDefs An array of definitions of components.
+ */
+function TKR_setUpComponentStore(componentDefs) {
+ const componentWords = [];
+ const docdict = {};
+ for (let i = 0; i < componentDefs.length; i++) {
+ const component = componentDefs[i];
+ componentWords.push(component.name);
+ docdict[component.name] = component.doc;
+ }
+
+ const completions = function(prefix, tofilter) {
+ const fullList = TKR_fullComplete(prefix, componentDefs);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+ const completable = function(inputValue, cursor) {
+ if (inputValue == '') return '*component';
+ return _AC_SimpleStore.prototype.completable.call(this, inputValue, cursor);
+ };
+
+ TKR_componentStore = new _AC_SimpleStore(componentWords, docdict);
+ TKR_componentStore.commaCompletes = false;
+ TKR_componentStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+ TKR_componentStore.completions = completions;
+ TKR_componentStore.completable = completable;
+
+ TKR_componentListStore = new _AC_SimpleStore(componentWords, docdict);
+ TKR_componentListStore.commaCompletes = false;
+ TKR_componentListStore.substitute = TKR_acSubstituteWithComma;
+ TKR_componentListStore.completions = completions;
+ TKR_componentListStore.completable = completable;
+}
+
+
+/**
+ * An array of definitions of all well-known issue labels. Each
+ * definition has the name of the label, and a docstring that
+ * describes its meaning.
+ */
+let TKR_labelWords = [];
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known issue
+ * labels for the current project. The store has some DIT-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} labelDefs An array of definitions of the project
+ * members. Each definition has a name and docstring.
+ */
+function TKR_setUpLabelStore(labelDefs) {
+ TKR_labelWords = [];
+ const TKR_labelPrefixes = [];
+ const labelPrefs = new Set();
+ const docdict = {};
+ for (let i = 0; i < labelDefs.length; i++) {
+ const label = labelDefs[i];
+ TKR_labelWords.push(label.name);
+ TKR_labelPrefixes.push(label.name.split('-')[0]);
+ docdict[label.name] = label.doc;
+ labelPrefs.add(label.name.split('-')[0]);
+ }
+ const labelPrefArray = Array.from(labelPrefs);
+ const labelPrefDefs = labelPrefArray.map((s) => ({name: s, doc: ''}));
+
+ TKR_labelStore = new _AC_SimpleStore(TKR_labelWords, docdict);
+
+ TKR_labelStore.commaCompletes = false;
+ TKR_labelStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ TKR_labelPrefixStore = new _AC_SimpleStore(TKR_labelPrefixes);
+
+ TKR_labelPrefixStore.commaCompletes = false;
+ TKR_labelPrefixStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ TKR_labelMultiStore = new _AC_SimpleStore(TKR_labelWords, docdict);
+
+ TKR_labelMultiStore.substitute = TKR_acSubstituteWithComma;
+
+ const completable = function(inputValue, cursor) {
+ if (cursor === 0) {
+ return '*label'; // Show every well-known label that is not redundant.
+ }
+ let start = 0;
+ for (let i = cursor; --i >= 0;) {
+ const c = inputValue.charAt(i);
+ if (c === ' ' || c === ',') {
+ start = i + 1;
+ break;
+ }
+ }
+ const questionPos = inputValue.indexOf('?');
+ if (questionPos >= 0) {
+ // Ignore any "?" character and anything after it.
+ inputValue = inputValue.substring(start, questionPos);
+ }
+ let result = inputValue.substring(start, cursor);
+ if (inputValue.lastIndexOf('-') > 0 && !ac_everTyped) {
+ // Act like a menu: offer all alternative values for the same prefix.
+ result = inputValue.substring(
+ start, Math.min(cursor, inputValue.lastIndexOf('-')));
+ }
+ if (inputValue.startsWith('Restrict-') && !ac_everTyped) {
+ // If user is in the middle of 2nd part, use that to narrow the choices.
+ result = inputValue;
+ // If they completed 2nd part, give all choices matching 2-part prefix.
+ if (inputValue.lastIndexOf('-') > 8) {
+ result = inputValue.substring(
+ start, Math.min(cursor, inputValue.lastIndexOf('-') + 1));
+ }
+ }
+
+ return result;
+ };
+
+ const computeAvoid = function() {
+ const labelTextFields = Array.from(
+ document.querySelectorAll('.labelinput'));
+ const otherTextFields = labelTextFields.filter(
+ (tf) => (tf !== ac_focusedInput && tf.value));
+ return otherTextFields.map((tf) => tf.value);
+ };
+
+
+ const completions = function(labeldic) {
+ return function(prefix, tofilter) {
+ let comps = TKR_fullComplete(prefix, labeldic);
+ if (comps === null) {
+ comps = _AC_SimpleStore.prototype.completions.call(
+ this, prefix, tofilter);
+ }
+
+ const filteredComps = [];
+ for (const completion of comps) {
+ const completionLower = completion.value.toLowerCase();
+ const labelPrefix = completionLower.split('-')[0];
+ let alreadyUsed = false;
+ const isExclusive = FindInArray(TKR_exclPrefixes, labelPrefix) !== -1;
+ if (isExclusive) {
+ for (const usedLabel of ac_avoidValues) {
+ if (usedLabel.startsWith(labelPrefix + '-')) {
+ alreadyUsed = true;
+ break;
+ }
+ }
+ }
+ if (!alreadyUsed) {
+ filteredComps.push(completion);
+ }
+ }
+
+ return filteredComps;
+ };
+ };
+
+ TKR_labelStore.computeAvoid = computeAvoid;
+ TKR_labelStore.completable = completable;
+ TKR_labelStore.completions = completions(labelDefs);
+
+ TKR_labelPrefixStore.completable = completable;
+ TKR_labelPrefixStore.completions = completions(labelPrefDefs);
+
+ TKR_labelMultiStore.completable = completable;
+ TKR_labelMultiStore.completions = completions(labelDefs);
+}
+
+
+/**
+ * Constuct a new autocomplete store with the given strings as choices.
+ * @param {Array} choices An array of autocomplete choices.
+ */
+function TKR_setUpAutoCompleteStore(choices) {
+ TKR_autoCompleteStore = new _AC_SimpleStore(choices);
+ const choicesDefs = [];
+ for (let i = 0; i < choices.length; ++i) {
+ choicesDefs.push({'name': choices[i], 'doc': ''});
+ }
+
+ /**
+ * Override the default completions() function to return a list of
+ * available choices. It proactively shows all choices when the user has
+ * not yet typed anything. It stops offering choices if the text field
+ * has a pretty long string in it already. It does not offer choices that
+ * have already been chosen.
+ */
+ TKR_autoCompleteStore.completions = function(prefix, tofilter) {
+ if (prefix.length > 18) {
+ return [];
+ }
+ let comps = TKR_fullComplete(prefix, choicesDefs);
+ if (comps == null) {
+ comps = _AC_SimpleStore.prototype.completions.call(
+ this, prefix, tofilter);
+ }
+
+ const usedComps = {};
+ const textFields = document.getElementsByTagName('input');
+ for (var i = 0; i < textFields.length; ++i) {
+ if (textFields[i].classList.contains('autocomplete')) {
+ usedComps[textFields[i].value] = true;
+ }
+ }
+ const unusedComps = [];
+ for (i = 0; i < comps.length; ++i) {
+ if (!usedComps[comps[i].value]) {
+ unusedComps.push(comps[i]);
+ }
+ }
+
+ return unusedComps;
+ };
+
+ /**
+ * Override the default completable() function with one that gives a
+ * special value when the user has not yet typed anything. This
+ * causes TKR_fullComplete() to show all choices. Also, always consider
+ * the whole textfield value as an input to completion matching. Otherwise,
+ * it would only consider the part after the last comma (which makes sense
+ * for gmail To: and Cc: address fields).
+ */
+ TKR_autoCompleteStore.completable = function(inputValue, cursor) {
+ if (inputValue == '') {
+ return '*ac';
+ }
+ return inputValue;
+ };
+
+ /**
+ * Override the default substitute() function to completely replace the
+ * contents of the text field when the user selects a completion. Otherwise,
+ * it would append, much like the Gmail To: and Cc: fields append autocomplete
+ * selections.
+ */
+ TKR_autoCompleteStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+
+ /**
+ * We consider the whole textfield to be one value, not a comma separated
+ * list. So, typing a ',' should not trigger an autocomplete selection.
+ */
+ TKR_autoCompleteStore.commaCompletes = false;
+}
+
+
+/**
+ * XMLHTTP object used to fetch autocomplete options from the server.
+ */
+const TKR_optionsXmlHttp = undefined;
+
+/**
+ * Contact the server to fetch the set of autocomplete options for the
+ * projects the user is contributor/member/owner of.
+ * @param {multiValue} boolean If set to true, the projectStore is configured to
+ * have support for multi-values (useful for example for saved queries where
+ * a query can apply to multiple projects).
+ */
+function TKR_fetchUserProjects(multiValue) {
+ // Set a request token to prevent XSRF leaking of user project lists.
+ const userRefs = [{displayName: window.CS_env.loggedInUserEmail}];
+ const userProjectsPromise = window.prpcClient.call(
+ 'monorail.Users', 'GetUsersProjects', {userRefs});
+ userProjectsPromise.then((response) => {
+ const userProjects = response.usersProjects[0];
+ const projects = (userProjects.ownerOf || [])
+ .concat(userProjects.memberOf || [])
+ .concat(userProjects.contributorTo || []);
+ projects.sort();
+ if (projects) {
+ TKR_setUpProjectStore(projects, multiValue);
+ }
+ });
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the projects that the
+ * current user has visibility into. The store has some monorail-specific
+ * methods.
+ * @param {Array} projects An array of project names.
+ * @param {boolean} multiValue Determines whether the store should support
+ * multiple values.
+ */
+function TKR_setUpProjectStore(projects, multiValue) {
+ const projectsDefs = [];
+ const docdict = {};
+ for (let i = 0; i < projects.length; ++i) {
+ projectsDefs.push({'name': projects[i], 'doc': ''});
+ docdict[projects[i]] = '';
+ }
+
+ TKR_projectStore = new _AC_SimpleStore(projects, docdict);
+ TKR_projectStore.commaCompletes = !multiValue;
+
+ if (multiValue) {
+ TKR_projectStore.substitute = TKR_acSubstituteWithComma;
+ } else {
+ TKR_projectStore.substitute =
+ function(inputValue, cursor, completable, completion) {
+ return completion.value;
+ };
+ }
+
+ TKR_projectStore.completions = function(prefix, tofilter) {
+ const fullList = TKR_fullComplete(prefix, projectsDefs);
+ if (fullList) return fullList;
+ return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+ };
+
+ TKR_projectStore.completable = function(inputValue, cursor) {
+ if (inputValue == '') return '*project';
+ if (multiValue) {
+ return _AC_SimpleStore.prototype.completable.call(
+ this, inputValue, cursor);
+ } else {
+ return inputValue;
+ }
+ };
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListStatuses to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} statusesResponse A pRPC ListStatusesResponse object.
+ */
+function TKR_convertStatuses(statusesResponse) {
+ const statusDefs = statusesResponse.statusDefs || [];
+ const jsonData = {};
+
+ // Split statusDefs into open and closed name-doc objects.
+ jsonData.open = [];
+ jsonData.closed = [];
+ for (const s of statusDefs) {
+ if (!s.deprecated) {
+ const item = {
+ name: s.status,
+ doc: s.docstring,
+ };
+ if (s.meansOpen) {
+ jsonData.open.push(item);
+ } else {
+ jsonData.closed.push(item);
+ }
+ }
+ }
+
+ jsonData.strict = statusesResponse.restrictToKnown;
+
+ return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListComponents to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} componentsResponse A pRPC ListComponentsResponse object.
+ */
+function TKR_convertComponents(componentsResponse) {
+ const componentDefs = (componentsResponse.componentDefs || []);
+ const jsonData = {};
+
+ // Filter out deprecated components and normalize to name-doc object.
+ jsonData.components = [];
+ for (const c of componentDefs) {
+ if (!c.deprecated) {
+ jsonData.components.push({
+ name: c.path,
+ doc: c.docstring,
+ });
+ }
+ }
+
+ return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects GetLabelOptions
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {object} labelsResponse A pRPC GetLabelOptionsResponse.
+ * @param {Array<FieldDef>=} fieldDefs FieldDefs from a project config, used to
+ * mask labels that are used to implement custom enum fields.
+ */
+function TKR_convertLabels(labelsResponse, fieldDefs = []) {
+ const labelDefs = (labelsResponse.labelDefs || []);
+ const exclusiveLabelPrefixes = (labelsResponse.exclusiveLabelPrefixes || []);
+ const jsonData = {};
+
+ const maskedLabels = new Set();
+ fieldDefs.forEach((fd) => {
+ if (fd.enumChoices) {
+ fd.enumChoices.forEach(({label}) => {
+ maskedLabels.add(`${fd.fieldRef.fieldName}-${label}`);
+ });
+ }
+ });
+
+ jsonData.labels = labelDefs.filter(({label}) => !maskedLabels.has(label)).map(
+ (label) => ({name: label.label, doc: label.docstring}));
+
+ jsonData.excl_prefixes = exclusiveLabelPrefixes.map(
+ (prefix) => prefix.toLowerCase());
+
+ return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects GetVisibleMembers
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {object?} visibleMembersResponse A pRPC GetVisibleMembersResponse.
+ * @return {{memberEmails: {name: string}, nonGroupEmails: {name: string}}}
+ */
+function TKR_convertVisibleMembers(visibleMembersResponse) {
+ if (!visibleMembersResponse) {
+ visibleMembersResponse = {};
+ }
+ const groupRefs = (visibleMembersResponse.groupRefs || []);
+ const userRefs = (visibleMembersResponse.userRefs || []);
+ const jsonData = {};
+
+ const groupEmails = new Set(groupRefs.map(
+ (groupRef) => groupRef.displayName));
+
+ jsonData.memberEmails = userRefs.map(
+ (userRef) => ({name: userRef.displayName}));
+ jsonData.nonGroupEmails = jsonData.memberEmails.filter(
+ (memberEmail) => !groupEmails.has(memberEmail));
+
+ return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListFields to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} fieldsResponse A pRPC ListFieldsResponse object.
+ */
+function TKR_convertFields(fieldsResponse) {
+ const fieldDefs = (fieldsResponse.fieldDefs || []);
+ const jsonData = {};
+
+ jsonData.fields = fieldDefs.map((field) =>
+ ({
+ field_id: field.fieldRef.fieldId,
+ field_name: field.fieldRef.fieldName,
+ field_type: field.fieldRef.type,
+ docstring: field.docstring,
+ choices: (field.enumChoices || []).map(
+ (choice) => ({name: choice.label, doc: choice.docstring})),
+ qualifiedMembers: (field.userChoices || []).map(
+ (userRef) => ({name: userRef.displayName})),
+ }),
+ );
+
+ return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Features ListHotlistsByUser
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {Array<HotlistV0>} hotlists A lists of hotlists
+ * @return {Array<{ref_str: string, summary: string}>}
+ */
+function TKR_convertHotlists(hotlists) {
+ if (hotlists === undefined) {
+ return [];
+ }
+
+ const seen = new Set();
+ const ambiguousNames = new Set();
+
+ hotlists.forEach((hotlist) => {
+ if (seen.has(hotlist.name)) {
+ ambiguousNames.add(hotlist.name);
+ }
+ seen.add(hotlist.name);
+ });
+
+ return hotlists.map((hotlist) => {
+ let ref_str = hotlist.name;
+ if (ambiguousNames.has(hotlist.name)) {
+ ref_str = hotlist.owner_ref.display_name + ':' + ref_str;
+ }
+ return {ref_str: ref_str, summary: hotlist.summary};
+ });
+}
+
+
+/**
+ * Initializes hotlists in autocomplete store.
+ * @param {Array<HotlistV0>} hotlists
+ */
+function TKR_populateHotlistAutocomplete(hotlists) {
+ TKR_setUpHotlistsStore(TKR_convertHotlists(hotlists));
+}
+
+
+/**
+ * Add project config data that's already been fetched to the legacy
+ * autocomplete.
+ * @param {Config} projectConfig Returned projectConfig data.
+ * @param {GetVisibleMembersResponse} visibleMembers
+ * @param {Array<string>} customPermissions
+ */
+function TKR_populateAutocomplete(projectConfig, visibleMembers,
+ customPermissions = []) {
+ const {statusDefs, componentDefs, labelDefs, fieldDefs,
+ exclusiveLabelPrefixes, projectName} = projectConfig;
+
+ const {memberEmails, nonGroupEmails} =
+ TKR_convertVisibleMembers(visibleMembers);
+ TKR_setUpMemberStore(memberEmails, nonGroupEmails);
+ TKR_prepOwnerField(memberEmails);
+
+ const {open, closed, strict} = TKR_convertStatuses({statusDefs});
+ TKR_setUpStatusStore(open, closed);
+ TKR_restrict_to_known = strict;
+
+ const {components} = TKR_convertComponents({componentDefs});
+ TKR_setUpComponentStore(components);
+
+ const {excl_prefixes, labels} = TKR_convertLabels(
+ {labelDefs, exclusiveLabelPrefixes}, fieldDefs);
+ TKR_exclPrefixes = excl_prefixes;
+ TKR_setUpLabelStore(labels);
+
+ const {fields} = TKR_convertFields({fieldDefs});
+ TKR_setUpUserAutocompleteStores(fields);
+
+ /* QuickEdit is not yet in Monorail. crbug.com/monorail/1926
+ TKR_setUpQuickEditStore(
+ jsonData.labels, jsonData.memberEmails, jsonData.open, jsonData.closed,
+ jsonData.nonGroupEmails);
+ */
+
+ // We need to wait until both exclusive prefixes (in configPromise) and
+ // labels (in labelsPromise) have been read.
+ TKR_prepLabelAC(TKR_labelFieldIDPrefix);
+
+ TKR_setUpSearchStore(
+ labels, memberEmails, open, closed,
+ components, fields, nonGroupEmails);
+
+ TKR_setUpCustomPermissionsStore(customPermissions);
+}
diff --git a/static/js/tracker/tracker-components.js b/static/js/tracker/tracker-components.js
new file mode 100644
index 0000000..633d70b
--- /dev/null
+++ b/static/js/tracker/tracker-components.js
@@ -0,0 +1,64 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS code for editing components and component definitions.
+ */
+
+var TKR_leafNameXmlHttp;
+
+var TKR_leafNameRE = /^[a-zA-Z]([-_]?[a-zA-Z0-9])+$/;
+var TKR_oldName = '';
+
+/**
+ * Function to validate the component leaf name..
+ * @param {string} projectName Current project name.
+ * @param {string} parentPath Path to this component's parent.
+ * @param {string} originalName Original leaf name, keeping that is always OK.
+ * @param {string} token security token.
+ */
+function TKR_checkLeafName(projectName, parentPath, originalName, token) {
+ var name = $('leaf_name').value;
+ var feedback = $('leafnamefeedback');
+ if (name == originalName) {
+ $('submit_btn').disabled = '';
+ feedback.textContent = '';
+ } else if (name != TKR_oldName) {
+ $('submit_btn').disabled = 'disabled';
+ if (name == '') {
+ feedback.textContent = 'Please choose a name';
+ } else if (!TKR_leafNameRE.test(name)) {
+ feedback.textContent = 'Invalid component name';
+ } else if (name.length > 30) {
+ feedback.textContent = 'Name is too long';
+ } else {
+ TKR_checkLeafNameOnServer(projectName, parentPath, name, token);
+ }
+ }
+ TKR_oldName = name;
+}
+
+
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName Current project name.
+ * @param {string} leafName The proposed leaf name.
+ * @param {string} token security token.
+ */
+async function TKR_checkLeafNameOnServer(projectName, parentPath, leafName) {
+ const message = {
+ project_name: projectName,
+ parent_path: parentPath,
+ component_name: leafName
+ };
+ const response = await window.prpcClient.call(
+ 'monorail.Projects', 'CheckComponentName', message);
+
+ $('leafnamefeedback').textContent = response.error || '';
+ $('submit_btn').disabled = response.error ? 'disabled' : '';
+}
diff --git a/static/js/tracker/tracker-dd.js b/static/js/tracker/tracker-dd.js
new file mode 100644
index 0000000..e7b4c1e
--- /dev/null
+++ b/static/js/tracker/tracker-dd.js
@@ -0,0 +1,132 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by Monorail to control drag-and-drop re-orderable lists
+ *
+ */
+
+/**
+ * Initializes the drag-and-drop functionality on the elements of a
+ * container node.
+ * TODO(lukasperaza): allow bulk drag-and-drop
+ * @param {Element} container The HTML container element to turn into
+ * a drag-and-drop list. The items of the list must have the
+ * class 'drag_item'
+ */
+function TKR_initDragAndDrop(container, opt_onDrop, opt_preventMultiple) {
+ let dragSrc = null;
+ let dragLocation = null;
+ let dragItems = container.getElementsByClassName('drag_item');
+ let target = null;
+
+ opt_preventMultiple = opt_preventMultiple || false;
+ opt_onDrop = opt_onDrop || function() {};
+
+ function _handleMouseDown(event) {
+ target = event.target;
+ }
+
+ function _handleDragStart(event) {
+ let el = event.currentTarget;
+ let gripper = el.getElementsByClassName('gripper');
+ if (gripper.length && !gripper[0].contains(target)) {
+ event.preventDefault();
+ return;
+ }
+ el.style.opacity = 0.4;
+ event.dataTransfer.setData('text/html', el.outerHTML);
+ event.dataTransfer.dropEffect = 'move';
+ dragSrc = el;
+ }
+
+ function inRect(rect, x, y) {
+ if (x < rect.left || x > rect.right) {
+ return '';
+ } else if (rect.top <= y && y <= rect.top + rect.height / 2) {
+ return 'top';
+ } else {
+ return 'bottom';
+ }
+ }
+
+ function _handleDragOver(event) {
+ if (dragSrc == null) {
+ return true;
+ }
+ event.preventDefault();
+ let el = event.currentTarget;
+ let rect = el.getBoundingClientRect(),
+ classes = el.classList;
+ let section = inRect(rect, event.clientX, event.clientY);
+ if (section == 'top' && !classes.contains('top')) {
+ dragLocation = 'top';
+ classes.remove('bottom');
+ classes.add('top');
+ } else if (section == 'bottom' && !classes.contains('bottom')) {
+ dragLocation = 'bottom';
+ classes.remove('top');
+ classes.add('bottom');
+ }
+ return false;
+ }
+
+ function removeClasses(el) {
+ el.classList.remove('top');
+ el.classList.remove('bottom');
+ }
+
+ function _handleDragDrop(event) {
+ let el = event.currentTarget;
+ if (dragSrc == null || el == dragSrc) {
+ return true;
+ }
+
+ if (opt_preventMultiple) {
+ let dragItems = container.getElementsByClassName('drag_item');
+ for (let i = 0; i < dragItems.length; i++) {
+ dragItems[i].setAttribute('draggable', false);
+ }
+ }
+
+ let srcID = dragSrc.getAttribute('data-id');
+ let id = el.getAttribute('data-id');
+
+ if (dragLocation == 'top') {
+ el.parentNode.insertBefore(dragSrc, el);
+ opt_onDrop(srcID, id, 'above');
+ } else if (dragLocation == 'bottom') {
+ el.parentNode.insertBefore(dragSrc, el.nextSibling);
+ opt_onDrop(srcID, id, 'below');
+ }
+ dragSrc.style.opacity = 0.4;
+ dragSrc = null;
+ }
+
+ function _handleDragEnd(event) {
+ if (dragSrc) {
+ dragSrc.style.opacity = 1;
+ dragSrc = null;
+ }
+ for (let i = 0; i < dragItems.length; i++) {
+ removeClasses(dragItems[i]);
+ }
+ }
+
+ for (let i = 0; i < dragItems.length; i++) {
+ let el = dragItems[i];
+ el.setAttribute('draggable', true);
+ el.addEventListener('mousedown', _handleMouseDown);
+ el.addEventListener('dragstart', _handleDragStart);
+ el.addEventListener('dragover', _handleDragOver);
+ el.addEventListener('drop', _handleDragDrop);
+ el.addEventListener('dragend', _handleDragEnd);
+ el.addEventListener('dragleave', function(event) {
+ removeClasses(event.currentTarget);
+ });
+ }
+}
diff --git a/static/js/tracker/tracker-display.js b/static/js/tracker/tracker-display.js
new file mode 100644
index 0000000..23b9dcf
--- /dev/null
+++ b/static/js/tracker/tracker-display.js
@@ -0,0 +1,322 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by Monorail to control the display of elements on
+ * the page, rollovers, and popup menus.
+ *
+ */
+
+
+/**
+ * Show a popup menu below a specified element. Optional x and y deltas can be
+ * used to fine-tune placement.
+ * @param {string} id The HTML id of the popup menu.
+ * @param {Element} el The HTML element that the popup should appear near.
+ * @param {number} opt_deltaX Optional X offset to finetune placement.
+ * @param {number} opt_deltaY Optional Y offset to finetune placement.
+ * @param {Element} opt_menuButton The HTML element for a menu button that
+ * was pressed to open the menu. When a button was used, we need to ignore
+ * the first "click" event, otherwise the menu will immediately close.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showBelow(id, el, opt_deltaX, opt_deltaY, opt_menuButton) {
+ let popupDiv = $(id);
+ let elBounds = nodeBounds(el);
+ let startX = elBounds.x;
+ let startY = elBounds.y + elBounds.h;
+ if (BR_IsIE()) {
+ startX -= 1;
+ startY -= 2;
+ }
+ if (BR_IsSafari()) {
+ startX += 1;
+ }
+ popupDiv.style.display = 'block'; // needed so that offsetWidth != 0
+
+ popupDiv.style.left = '-2000px';
+ if (id == 'pop_dot' || id == 'redoMenu') {
+ startX = startX - popupDiv.offsetWidth + el.offsetWidth;
+ }
+ if (opt_deltaX) startX += opt_deltaX;
+ if (opt_deltaY) startY += opt_deltaY;
+ popupDiv.style.left = (startX)+'px';
+ popupDiv.style.top = (startY)+'px';
+ let popup = new TKR_MyPopup(popupDiv, opt_menuButton);
+ popup.show();
+ return false;
+}
+
+
+/**
+ * Show a popup menu to the right of a specified element. If there is not
+ * enough space to the right, then it will open to the left side instead.
+ * Optional x and y deltas can be used to fine-tune placement.
+ * TODO(jrobbins): reduce redundancy with function above.
+ * @param {string} id The HTML id of the popup menu.
+ * @param {Element} el The HTML element that the popup should appear near.
+ * @param {number} opt_deltaX Optional X offset to finetune placement.
+ * @param {number} opt_deltaY Optional Y offset to finetune placement.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showRight(id, el, opt_deltaX, opt_deltaY) {
+ let popupDiv = $(id);
+ let elBounds = nodeBounds(el);
+ let startX = elBounds.x + elBounds.w;
+ let startY = elBounds.y;
+
+ // Calculate pageSize.w and pageSize.h
+ let docElemWidth = document.documentElement.clientWidth;
+ let docElemHeight = document.documentElement.clientHeight;
+ let pageSize = {
+ w: (window.innerWidth || docElemWidth && docElemWidth > 0 ?
+ docElemWidth : document.body.clientWidth) || 1,
+ h: (window.innerHeight || docElemHeight && docElemHeight > 0 ?
+ docElemHeight : document.body.clientHeight) || 1,
+ };
+
+ // We need to make the popupDiv visible in order to capture its width
+ popupDiv.style.display = 'block';
+ let popupDivBounds = nodeBounds(popupDiv);
+
+ // Show popup to the left
+ if (startX + popupDivBounds.w > pageSize.w) {
+ startX = elBounds.x - popupDivBounds.w;
+ if (BR_IsIE()) {
+ startX -= 4;
+ startY -= 2;
+ }
+ if (BR_IsNav()) {
+ startX -= 2;
+ }
+ if (BR_IsSafari()) {
+ startX += -1;
+ }
+
+ // Show popup to the right
+ } else {
+ if (BR_IsIE()) {
+ startY -= 2;
+ }
+ if (BR_IsNav()) {
+ startX += 2;
+ }
+ if (BR_IsSafari()) {
+ startX += 3;
+ }
+ }
+
+ popupDiv.style.left = '-2000px';
+ popupDiv.style.position = 'absolute';
+ if (opt_deltaX) startX += opt_deltaX;
+ if (opt_deltaY) startY += opt_deltaY;
+ popupDiv.style.left = (startX)+'px';
+ popupDiv.style.top = (startY)+'px';
+ let popup = new TKR_MyPopup(popupDiv);
+ popup.show();
+ return false;
+}
+
+
+/**
+ * Close the specified popup menu and unregister it with the popup
+ * controller, otherwise old leftover popup instances can mess with
+ * the future display of menus.
+ * @param {string} id The HTML ID of the element to hide.
+ */
+function TKR_closePopup(id) {
+ let e = $(id);
+ if (e) {
+ for (let i = 0; i < gPopupController.activePopups_.length; ++i) {
+ if (e === gPopupController.activePopups_[i]._div) {
+ let popup = gPopupController.activePopups_[i];
+ popup.hide();
+ gPopupController.activePopups_.splice(i, 1);
+ return;
+ }
+ }
+ }
+}
+
+
+var TKR_allColumnNames = []; // Will be defined in HTML file.
+
+/**
+ * Close all popup menus. Also, reset the hover state of the menu item that
+ * was selected. The list of popup menu names is computed from the list of
+ * columns specified in the HTML for the issue list page.
+ * @param menuItem {Element} The menu item that the user clicked.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_closeAllPopups(menuItem) {
+ for (let col_index = 0; col_index < TKR_allColumnNames.length; col_index++) {
+ TKR_closePopup('pop_' + col_index);
+ TKR_closePopup('filter_' + col_index);
+ }
+ TKR_closePopup('pop_dot');
+ TKR_closePopup('redoMenu');
+ menuItem.classList.remove('hover');
+ return false;
+}
+
+
+/**
+ * Close all the submenus (of which, one may be currently open).
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_closeSubmenus() {
+ for (let col_index = 0; col_index < TKR_allColumnNames.length; col_index++) {
+ TKR_closePopup('filter_' + col_index);
+ }
+ return false;
+}
+
+
+/**
+ * Find the enclosing HTML element that controls this section of the
+ * page and set it to use CSS class "opened". That will make the
+ * section display in the opened state, regardless of what state is
+ * was in before.
+ * @param {Element} el The HTML element that the user clicked on.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showHidden(el) {
+ while (el) {
+ if (el.classList.contains('closed')) {
+ el.classList.remove('closed');
+ el.classList.add('opened');
+ return false;
+ }
+ if (el.classList.contains('opened')) {
+ return false;
+ }
+ el = el.parentNode;
+ }
+}
+
+
+/**
+ * Toggle the display of a column in the issue list page. That is
+ * done by adding or removing a CSS class of an enclosing HTML
+ * element, and by CSS rules that trigger based on that CSS class.
+ * @param {string} colName The name of the column to toggle,
+ * corresponds to a CSS class.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_toggleColumn(colName) {
+ let controlDiv = $('colcontrol');
+ if (controlDiv.classList.contains(colName)) {
+ controlDiv.classList.remove(colName);
+ } else {
+ controlDiv.classList.add(colName);
+ }
+ return false;
+}
+
+
+/**
+ * Toggle the display of a set of rows in the issue list page. That is
+ * done by adding or removing a CSS class of an enclosing HTML
+ * element, and by CSS rules that trigger based on that CSS class.
+ * TODO(jrobbins): actually, this automatically hides the other groups.
+ * @param {string} rowClassName The name of the row group to toggle,
+ * corresponds to a CSS class.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_toggleRows(rowClassName) {
+ let controlDiv = $('colcontrol');
+ controlDiv.classList.add('hide_pri_groups');
+ controlDiv.classList.add('hide_mile_groups');
+ controlDiv.classList.add('hide_stat_groups');
+ TKR_toggleColumn(rowClassName);
+ return false;
+}
+
+
+/**
+ * A simple class that can manage the display of a popup menu. Instances
+ * of this class are used by popup_controller.js.
+ * @param {Element} div The div that contains the popup menu.
+ * @param {Element} opt_launcherEl The button that launched the popup menu,
+ * if any.
+ * @constructor
+ */
+function TKR_MyPopup(div, opt_launcherEl) {
+ this._div = div;
+ this._launcher = opt_launcherEl;
+ this._isVisible = false;
+}
+
+
+/**
+ * Show a popup menu. This method registers the popup with popup_controller.
+ */
+TKR_MyPopup.prototype.show = function() {
+ this._div.style.display = 'block';
+ this._isVisible = true;
+ PC_addPopup(this);
+};
+
+
+/**
+ * Show a popup menu. This method is called from the deactive method,
+ * which is called by popup_controller.
+ */
+TKR_MyPopup.prototype.hide = function() {
+ this._div.style.display = 'none';
+ this._isVisible = false;
+};
+
+
+/**
+ * When the popup_controller gets a user click, it calls deactive() on
+ * every active popup to check if the click should close that popup.
+ */
+TKR_MyPopup.prototype.deactivate = function(e) {
+ if (this._isVisible) {
+ let p = GetMousePosition(e);
+ if (nodeBounds(this._div).contains(p)) {
+ return false; // use clicked on popup, remain visible
+ } else if (this._launcher && nodeBounds(this._launcher).contains(p)) {
+ this._launcher = null;
+ return false; // mouseup element that launched menu, remain visible
+ } else {
+ this.hide();
+ return true; // clicked outside popup, make invisible
+ }
+ } else {
+ return true; // already deactivated, not visible
+ }
+};
+
+
+/**
+ * Highlight the issue row on the list page that contains the given
+ * checkbox.
+ * @param {Element} cb The checkbox that the user changed.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_highlightRow(el) {
+ let checked = el.checked;
+ while (el && el.tagName != 'TR') {
+ el = el.parentNode;
+ }
+ if (checked) {
+ el.classList.add('selected');
+ } else {
+ el.classList.remove('selected');
+ }
+ return false;
+}
diff --git a/static/js/tracker/tracker-editing.js b/static/js/tracker/tracker-editing.js
new file mode 100644
index 0000000..d53b515
--- /dev/null
+++ b/static/js/tracker/tracker-editing.js
@@ -0,0 +1,1823 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+/* eslint-disable prefer-const */
+
+/**
+ * This file contains JS functions that support various issue editing
+ * features of Monorail. These editing features include: selecting
+ * issues on the issue list page, adding attachments, expanding and
+ * collapsing the issue editing form, and starring issues.
+ *
+ * Browser compatability: IE6, IE7, FF1.0+, Safari.
+ */
+
+
+/**
+ * Here are some string constants that are used repeatedly in the code.
+ */
+let TKR_SELECTED_CLASS = 'selected';
+let TKR_UNDEF_CLASS = 'undef';
+let TKR_NOVEL_CLASS = 'novel';
+let TKR_EXCL_CONFICT_CLASS = 'exclconflict';
+let TKR_QUESTION_MARK_CLASS = 'questionmark';
+let TKR_ATTACHPROMPT_ID = 'attachprompt';
+let TKR_ATTACHAFILE_ID = 'attachafile';
+let TKR_ATTACHMAXSIZE_ID = 'attachmaxsize';
+let TKR_CURRENT_TEMPLATE_INDEX_ID = 'current_template_index';
+let TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID = 'members_only_checkbox';
+let TKR_PROMPT_SUMMARY_EDITOR_ID = 'summary_editor';
+let TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID =
+ 'summary_must_be_edited_checkbox';
+let TKR_PROMPT_CONTENT_EDITOR_ID = 'content_editor';
+let TKR_PROMPT_STATUS_EDITOR_ID = 'status_editor';
+let TKR_PROMPT_OWNER_EDITOR_ID = 'owner_editor';
+let TKR_PROMPT_ADMIN_NAMES_EDITOR_ID = 'admin_names_editor';
+let TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID =
+ 'owner_defaults_to_member_checkbox';
+let TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID =
+ 'owner_defaults_to_member_area';
+let TKR_COMPONENT_REQUIRED_CHECKBOX_ID =
+ 'component_required_checkbox';
+let TKR_PROMPT_COMPONENTS_EDITOR_ID = 'components_editor';
+let TKR_FIELD_EDITOR_ID_PREFIX = 'tmpl_custom_';
+let TKR_PROMPT_LABELS_EDITOR_ID_PREFIX = 'label';
+let TKR_CONFIRMAREA_ID = 'confirmarea';
+let TKR_DISCARD_YOUR_CHANGES = 'Discard your changes?';
+// Note, users cannot enter '<'.
+let TKR_DELETED_PROMPT_NAME = '<DELETED>';
+// Display warning if labels contain the following prefixes.
+// The following list is the same as tracker_constants.RESERVED_PREFIXES except
+// for the 'hotlist' prefix. 'hostlist' will be added when it comes a full
+// feature and when projects that use 'Hostlist-*' labels are transitioned off.
+let TKR_LABEL_RESERVED_PREFIXES = [
+ 'id', 'project', 'reporter', 'summary', 'status', 'owner', 'cc',
+ 'attachments', 'attachment', 'component', 'opened', 'closed',
+ 'modified', 'is', 'has', 'blockedon', 'blocking', 'blocked', 'mergedinto',
+ 'stars', 'starredby', 'description', 'comment', 'commentby', 'label',
+ 'rank', 'explicit_status', 'derived_status', 'explicit_owner',
+ 'derived_owner', 'explicit_cc', 'derived_cc', 'explicit_label',
+ 'derived_label', 'last_comment_by', 'exact_component',
+ 'explicit_component', 'derived_component'];
+
+
+/**
+ * Appends a given child element to the DOM based on parameters.
+ * @param {HTMLElement} parentEl
+ * @param {string} tag
+ * @param {string} optClassName
+ * @param {string} optID
+ * @param {string} optText
+ * @param {string} optStyle
+*/
+function TKR_createChild(parentEl, tag, optClassName, optID, optText, optStyle) {
+ let el = document.createElement(tag);
+ if (optClassName) el.classList.add(optClassName);
+ if (optID) el.id = optID;
+ if (optText) el.textContent = optText;
+ if (optStyle) el.setAttribute('style', optStyle);
+ parentEl.appendChild(el);
+ return el;
+}
+
+/**
+ * Select all the issues on the issue list page.
+ */
+function TKR_selectAllIssues() {
+ TKR_selectIssues(true);
+}
+
+
+/**
+ * Function to deselect all the issues on the issue list page.
+ */
+function TKR_selectNoneIssues() {
+ TKR_selectIssues(false);
+}
+
+
+/**
+ * Function to select or deselect all the issues on the issue list page.
+ * @param {boolean} checked True means select issues, False means deselect.
+ */
+function TKR_selectIssues(checked) {
+ let table = $('resultstable');
+ for (let r = 0; r < table.rows.length; ++r) {
+ let row = table.rows[r];
+ let firstCell = row.cells[0];
+ if (firstCell.tagName == 'TD') {
+ for (let e = 0; e < firstCell.childNodes.length; ++e) {
+ let element = firstCell.childNodes[e];
+ if (element.tagName == 'INPUT' && element.type == 'checkbox') {
+ element.checked = checked ? 'checked' : '';
+ if (checked) {
+ row.classList.add(TKR_SELECTED_CLASS);
+ } else {
+ row.classList.remove(TKR_SELECTED_CLASS);
+ }
+ }
+ }
+ }
+ }
+}
+
+
+/**
+ * The ID number to append to the next dynamically created file upload field.
+ */
+let TKR_nextFileID = 1;
+
+
+/**
+ * Function to dynamically create a new attachment upload field add
+ * insert it into the page DOM.
+ * @param {string} id The id of the parent HTML element.
+ *
+ * TODO(lukasperaza): use different nextFileID for separate forms on same page,
+ * e.g. issue update form and issue description update form
+ */
+function TKR_addAttachmentFields(id, attachprompt_id,
+ attachafile_id, attachmaxsize_id) {
+ if (TKR_nextFileID >= 16) {
+ return;
+ }
+ if (typeof attachprompt_id === 'undefined') {
+ attachprompt_id = TKR_ATTACHPROMPT_ID;
+ }
+ if (typeof attachafile_id === 'undefined') {
+ attachafile_id = TKR_ATTACHAFILE_ID;
+ }
+ if (typeof attachmaxsize_id === 'undefined') {
+ attachmaxsize_id = TKR_ATTACHMAXSIZE_ID;
+ }
+ let el = $(id);
+ el.style.marginTop = '4px';
+ let div = document.createElement('div');
+ var id = 'file' + TKR_nextFileID;
+ let label = TKR_createChild(div, 'label', null, null, 'Attach file:');
+ label.setAttribute('for', id);
+ let input = TKR_createChild(
+ div, 'input', null, id, null, 'width:auto;margin-left:17px');
+ input.setAttribute('type', 'file');
+ input.name = id;
+ let removeLink = TKR_createChild(
+ div, 'a', null, null, 'Remove', 'font-size:x-small');
+ removeLink.href = '#';
+ removeLink.addEventListener('click', function(event) {
+ let target = event.target;
+ $(attachafile_id).focus();
+ target.parentNode.parentNode.removeChild(target.parentNode);
+ event.preventDefault();
+ });
+ el.appendChild(div);
+ el.querySelector('input').focus();
+ ++TKR_nextFileID;
+ if (TKR_nextFileID < 16) {
+ $(attachafile_id).textContent = 'Attach another file';
+ } else {
+ $(attachprompt_id).style.display = 'none';
+ }
+ $(attachmaxsize_id).style.display = '';
+}
+
+
+/**
+ * Function to display the form so that the user can update an issue.
+ */
+function TKR_openIssueUpdateForm() {
+ TKR_showHidden($('makechangesarea'));
+ TKR_goToAnchor('makechanges');
+ TKR_forceProperTableWidth();
+ window.setTimeout(
+ function() {
+ document.getElementById('addCommentTextArea').focus();
+ },
+ 100);
+}
+
+
+/**
+ * The index of the template that is currently selected for editing
+ * on the administration page for issues.
+ */
+let TKR_currentTemplateIndex = 0;
+
+
+/**
+ * Array of field IDs that are defined in the current project, set by call to setFieldIDs().
+ */
+let TKR_fieldIDs = [];
+
+
+function TKR_setFieldIDs(fieldIDs) {
+ TKR_fieldIDs = fieldIDs;
+}
+
+
+/**
+ * This function displays the appropriate template text in a text field.
+ * It is called after the user has selected one template to view/edit.
+ * @param {Element} widget The list widget containing the list of templates.
+ */
+function TKR_selectTemplate(widget) {
+ TKR_showHidden($('edit_panel'));
+ TKR_currentTemplateIndex = widget.value;
+ $(TKR_CURRENT_TEMPLATE_INDEX_ID).value = TKR_currentTemplateIndex;
+
+ let content_editor = $(TKR_PROMPT_CONTENT_EDITOR_ID);
+ TKR_makeDefined(content_editor);
+
+ let can_edit = $('can_edit_' + TKR_currentTemplateIndex).value == 'yes';
+ let disabled = can_edit ? '' : 'disabled';
+
+ $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).disabled = disabled;
+ $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).checked = $(
+ 'members_only_' + TKR_currentTemplateIndex).value == 'yes';
+ $(TKR_PROMPT_SUMMARY_EDITOR_ID).disabled = disabled;
+ $(TKR_PROMPT_SUMMARY_EDITOR_ID).value = $(
+ 'summary_' + TKR_currentTemplateIndex).value;
+ $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).disabled = disabled;
+ $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).checked = $(
+ 'summary_must_be_edited_' + TKR_currentTemplateIndex).value == 'yes';
+ content_editor.disabled = disabled;
+ content_editor.value = $('content_' + TKR_currentTemplateIndex).value;
+ $(TKR_PROMPT_STATUS_EDITOR_ID).disabled = disabled;
+ $(TKR_PROMPT_STATUS_EDITOR_ID).value = $(
+ 'status_' + TKR_currentTemplateIndex).value;
+ $(TKR_PROMPT_OWNER_EDITOR_ID).disabled = disabled;
+ $(TKR_PROMPT_OWNER_EDITOR_ID).value = $(
+ 'owner_' + TKR_currentTemplateIndex).value;
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).disabled = disabled;
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).checked = $(
+ 'owner_defaults_to_member_' + TKR_currentTemplateIndex).value == 'yes';
+ $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).disabled = disabled;
+ $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).checked = $(
+ 'component_required_' + TKR_currentTemplateIndex).value == 'yes';
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).disabled = disabled;
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).style.display =
+ $(TKR_PROMPT_OWNER_EDITOR_ID).value ? 'none' : '';
+ $(TKR_PROMPT_COMPONENTS_EDITOR_ID).disabled = disabled;
+ $(TKR_PROMPT_COMPONENTS_EDITOR_ID).value = $(
+ 'components_' + TKR_currentTemplateIndex).value;
+
+ // Blank out all custom field editors first, then fill them in during the next loop.
+ for (var i = 0; i < TKR_fieldIDs.length; i++) {
+ let fieldEditor = $(TKR_FIELD_EDITOR_ID_PREFIX + TKR_fieldIDs[i]);
+ let holder = $('field_value_' + TKR_currentTemplateIndex + '_' + TKR_fieldIDs[i]);
+ if (fieldEditor) {
+ fieldEditor.disabled = disabled;
+ fieldEditor.value = holder ? holder.value : '';
+ }
+ }
+
+ var i = 0;
+ while ($(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i)) {
+ $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).disabled = disabled;
+ $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).value =
+ $('label_' + TKR_currentTemplateIndex + '_' + i).value;
+ i++;
+ }
+
+ $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).disabled = disabled;
+ $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).value = $(
+ 'admin_names_' + TKR_currentTemplateIndex).value;
+
+ let numNonDeletedTemplates = 0;
+ for (var i = 0; i < TKR_templateNames.length; i++) {
+ if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+ numNonDeletedTemplates++;
+ }
+ }
+ if ($('delbtn')) {
+ if (numNonDeletedTemplates > 1) {
+ $('delbtn').disabled='';
+ } else { // Don't allow the last template to be deleted.
+ $('delbtn').disabled='disabled';
+ }
+ }
+}
+
+
+var TKR_templateNames = []; // Exported in tracker-onload.js
+
+
+/**
+ * Create a new issue template and add the needed form fields to the DOM.
+ */
+function TKR_newTemplate() {
+ let newIndex = TKR_templateNames.length;
+ let templateName = prompt('Name of new template?', '');
+ templateName = templateName.replace(
+ /[&<>"]/g, '', // " help emacs highlighting
+ );
+ if (!templateName) return;
+
+ for (let i = 0; i < TKR_templateNames.length; i++) {
+ if (templateName == TKR_templateNames[i]) {
+ alert('Please choose a unique name.');
+ return;
+ }
+ }
+
+ TKR_addTemplateHiddenFields(newIndex, templateName);
+ TKR_templateNames.push(templateName);
+
+ let templateOption = TKR_createChild(
+ $('template_menu'), 'option', null, null, templateName);
+ templateOption.value = newIndex;
+ templateOption.selected = 'selected';
+
+ let developerOption = TKR_createChild(
+ $('default_template_for_developers'), 'option', null, null, templateName);
+ developerOption.value = templateName;
+
+ let userOption = TKR_createChild(
+ $('default_template_for_users'), 'option', null, null, templateName);
+ userOption.value = templateName;
+
+ TKR_selectTemplate($('template_menu'));
+}
+
+
+/**
+ * Private function to append HTML for new hidden form fields
+ * for a new issue template to the issue admin form.
+ */
+function TKR_addTemplateHiddenFields(templateIndex, templateName) {
+ let parentEl = $('adminTemplates');
+ TKR_appendHiddenField(
+ parentEl, 'template_id_' + templateIndex, 'template_id_' + templateIndex, '0');
+ TKR_appendHiddenField(parentEl, 'name_' + templateIndex,
+ 'name_' + templateIndex, templateName);
+ TKR_appendHiddenField(parentEl, 'members_only_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'summary_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'summary_must_be_edited_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'content_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'status_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'owner_' + templateIndex);
+ TKR_appendHiddenField(
+ parentEl, 'owner_defaults_to_member_' + templateIndex,
+ 'owner_defaults_to_member_' + templateIndex, 'yes');
+ TKR_appendHiddenField(parentEl, 'component_required_' + templateIndex);
+ TKR_appendHiddenField(parentEl, 'components_' + templateIndex);
+
+ var i = 0;
+ while ($('label_0_' + i)) {
+ TKR_appendHiddenField(parentEl, 'label_' + templateIndex,
+ 'label_' + templateIndex + '_' + i);
+ i++;
+ }
+
+ for (var i = 0; i < TKR_fieldIDs.length; i++) {
+ let fieldId = 'field_value_' + templateIndex + '_' + TKR_fieldIDs[i];
+ TKR_appendHiddenField(parentEl, fieldId, fieldId);
+ }
+
+ TKR_appendHiddenField(parentEl, 'admin_names_' + templateIndex);
+ TKR_appendHiddenField(
+ parentEl, 'can_edit_' + templateIndex, 'can_edit_' + templateIndex,
+ 'yes');
+}
+
+
+/**
+ * Utility function to append string parts for one hidden field
+ * to the given array.
+ */
+function TKR_appendHiddenField(parentEl, name, opt_id, opt_value) {
+ let input = TKR_createChild(parentEl, 'input', null, opt_id || name);
+ input.setAttribute('type', 'hidden');
+ input.name = name;
+ input.value = opt_value || '';
+}
+
+
+/**
+ * Delete the currently selected issue template, and mark its hidden
+ * form field as deleted so that they will be ignored when submitted.
+ */
+function TKR_deleteTemplate() {
+ // Mark the current template name as deleted.
+ TKR_templateNames.splice(
+ TKR_currentTemplateIndex, 1, TKR_DELETED_PROMPT_NAME);
+ $('name_' + TKR_currentTemplateIndex).value = TKR_DELETED_PROMPT_NAME;
+ _toggleHidden($('edit_panel'));
+ $('delbtn').disabled = 'disabled';
+ TKR_rebuildTemplateMenu();
+ TKR_rebuildDefaultTemplateMenu('default_template_for_developers');
+ TKR_rebuildDefaultTemplateMenu('default_template_for_users');
+}
+
+/**
+ * Utility function to rebuild the template menu on the issue admin page.
+ */
+function TKR_rebuildTemplateMenu() {
+ let parentEl = $('template_menu');
+ while (parentEl.childNodes.length) {
+ parentEl.removeChild(parentEl.childNodes[0]);
+ }
+ for (let i = 0; i < TKR_templateNames.length; i++) {
+ if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+ let option = TKR_createChild(
+ parentEl, 'option', null, null, TKR_templateNames[i]);
+ option.value = i;
+ }
+ }
+}
+
+
+/**
+ * Utility function to rebuild a default template drop-down.
+ */
+function TKR_rebuildDefaultTemplateMenu(menuID) {
+ let defaultTemplateName = $(menuID).value;
+ let parentEl = $(menuID);
+ while (parentEl.childNodes.length) {
+ parentEl.removeChild(parentEl.childNodes[0]);
+ }
+ for (let i = 0; i < TKR_templateNames.length; i++) {
+ if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+ let option = TKR_createChild(
+ parentEl, 'option', null, null, TKR_templateNames[i]);
+ option.values = TKR_templateNames[i];
+ if (defaultTemplateName == TKR_templateNames[i]) {
+ option.setAttribute('selected', 'selected');
+ }
+ }
+ }
+}
+
+
+/**
+ * Change the issue template to the specified one.
+ * TODO(jrobbins): move to an AJAX implementation that would not reload page.
+ *
+ * @param {string} projectName The name of the current project.
+ * @param {string} templateName The name of the template to switch to.
+ */
+function TKR_switchTemplate(projectName, templateName) {
+ let ok = true;
+ if (TKR_isDirty()) {
+ ok = confirm('Switching to a different template will lose the text you entered.');
+ }
+ if (ok) {
+ TKR_initialFormValues = TKR_currentFormValues();
+ window.location = '/p/' + projectName +
+ '/issues/entry?template=' + templateName;
+ }
+}
+
+/**
+ * Function to remove a CSS class and initial tip from a text widget.
+ * Some text fields or text areas display gray textual tips to help the user
+ * make use of those widgets. When the user focuses on the field, the tip
+ * disappears and is made ready for user input (in the normal text color).
+ * @param {Element} el The form field that had the gray text tip.
+ */
+function TKR_makeDefined(el) {
+ if (el.classList.contains(TKR_UNDEF_CLASS)) {
+ el.classList.remove(TKR_UNDEF_CLASS);
+ el.value = '';
+ }
+}
+
+
+/**
+ * Save the contents of the visible issue template text area into a hidden
+ * text field for later submission.
+ * Called when the user has edited the text of a issue template.
+ */
+function TKR_saveTemplate() {
+ if (TKR_currentTemplateIndex) {
+ $('members_only_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).checked ? 'yes' : '';
+ $('summary_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_SUMMARY_EDITOR_ID).value;
+ $('summary_must_be_edited_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).checked ? 'yes' : '';
+ $('content_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_CONTENT_EDITOR_ID).value;
+ $('status_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_STATUS_EDITOR_ID).value;
+ $('owner_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_OWNER_EDITOR_ID).value;
+ $('owner_defaults_to_member_' + TKR_currentTemplateIndex).value =
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).checked ? 'yes' : '';
+ $('component_required_' + TKR_currentTemplateIndex).value =
+ $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).checked ? 'yes' : '';
+ $('components_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_COMPONENTS_EDITOR_ID).value;
+ $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).style.display =
+ $(TKR_PROMPT_OWNER_EDITOR_ID).value ? 'none' : '';
+
+ for (var i = 0; i < TKR_fieldIDs.length; i++) {
+ let fieldID = TKR_fieldIDs[i];
+ let fieldEditor = $(TKR_FIELD_EDITOR_ID_PREFIX + fieldID);
+ if (fieldEditor) {
+ _saveFieldValue(fieldID, fieldEditor.value);
+ }
+ }
+
+ var i = 0;
+ while ($('label_' + TKR_currentTemplateIndex + '_' + i)) {
+ $('label_' + TKR_currentTemplateIndex + '_' + i).value =
+ $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).value;
+ i++;
+ }
+
+ $('admin_names_' + TKR_currentTemplateIndex).value =
+ $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).value;
+ }
+}
+
+
+function _saveFieldValue(fieldID, val) {
+ let fieldValId = 'field_value_' + TKR_currentTemplateIndex + '_' + fieldID;
+ $(fieldValId).value = val;
+}
+
+
+/**
+ * This is a json string encoding of an array of form values after the initial
+ * page load. It is used for comparison on page unload to prompt the user
+ * before abandoning changes. It is initialized in TKR_onload().
+*/
+let TKR_initialFormValues;
+
+
+/**
+ * Returns a json string encoding of an array of all the values from user
+ * input fields of interest (omits search box, e.g.)
+ */
+function TKR_currentFormValues() {
+ let inputs = document.querySelectorAll('input, textarea, select, checkbox');
+ let values = [];
+
+ for (i = 0; i < inputs.length; i++) {
+ // Don't include blank inputs. This prevents a popup if the user
+ // clicks "add a row" for new labels but doesn't actually enter any
+ // text into them. Also ignore search box contents.
+ if (inputs[i].value && !inputs[i].hasAttribute('ignore-dirty') &&
+ inputs[i].name != 'token') {
+ values.push(inputs[i].value);
+ }
+ }
+
+ return JSON.stringify(values);
+}
+
+
+/**
+ * This function returns true if the user has made any edits to fields of
+ * interest.
+ */
+function TKR_isDirty() {
+ return TKR_initialFormValues != TKR_currentFormValues();
+}
+
+
+/**
+ * The user has clicked the 'Discard' button on the issue update form.
+ * If the form has been edited, ask if they are sure about discarding
+ * before then navigating to the given URL. This can go up to some
+ * other page, or reload the current page with a fresh form.
+ * @param {string} nextUrl The page to show after discarding.
+ */
+function TKR_confirmDiscardUpdate(nextUrl) {
+ if (!TKR_isDirty() || confirm(TKR_DISCARD_YOUR_CHANGES)) {
+ document.location = nextUrl;
+ }
+}
+
+
+/**
+ * The user has clicked the 'Discard' button on the issue entry form.
+ * If the form has been edited, this function asks if they are sure about
+ * discarding before doing it.
+ * @param {Element} discardButton The 'Discard' button.
+ */
+function TKR_confirmDiscardEntry(discardButton) {
+ if (!TKR_isDirty() || confirm(TKR_DISCARD_YOUR_CHANGES)) {
+ TKR_go('list');
+ }
+}
+
+
+/**
+ * Normally, we show 2 rows of label editing fields when updating an issue.
+ * However, if the issue has more than that many labels already, we make sure to
+ * show them all.
+ */
+function TKR_exposeExistingLabelFields() {
+ if ($('label3').value ||
+ $('label4').value ||
+ $('label5').value) {
+ if ($('addrow1')) {
+ _showID('LF_row2');
+ _hideID('addrow1');
+ }
+ }
+ if ($('label6').value ||
+ $('label7').value ||
+ $('label8').value) {
+ _showID('LF_row3');
+ _hideID('addrow2');
+ }
+ if ($('label9').value ||
+ $('label10').value ||
+ $('label11').value) {
+ _showID('LF_row4');
+ _hideID('addrow3');
+ }
+ if ($('label12').value ||
+ $('label13').value ||
+ $('label14').value) {
+ _showID('LF_row5');
+ _hideID('addrow4');
+ }
+ if ($('label15').value ||
+ $('label16').value ||
+ $('label17').value) {
+ _showID('LF_row6');
+ _hideID('addrow5');
+ }
+ if ($('label18').value ||
+ $('label19').value ||
+ $('label20').value) {
+ _showID('LF_row7');
+ _hideID('addrow6');
+ }
+ if ($('label21').value ||
+ $('label22').value ||
+ $('label23').value) {
+ _showID('LF_row8');
+ _hideID('addrow7');
+ }
+}
+
+
+/**
+ * Flag to indicate when the user has not yet caused any input events.
+ * We use this to clear the placeholder in the new issue summary field
+ * exactly once.
+ */
+let TKR_firstEvent = true;
+
+
+/**
+ * This is called in response to almost any user input event on the
+ * issue entry page. If the placeholder in the new issue sumary field has
+ * not yet been cleared, then this function clears it.
+ */
+function TKR_clearOnFirstEvent(initialSummary) {
+ if (TKR_firstEvent && $('summary').value == initialSummary) {
+ TKR_firstEvent = false;
+ $('summary').value = TKR_keepJustSummaryPrefixes($('summary').value);
+ }
+}
+
+/**
+ * Clear the summary, except for any prefixes of the form "[bracketed text]"
+ * or "keyword:". If there were any, add a trailing space. This is useful
+ * to people who like to encode issue classification info in the summary line.
+ */
+function TKR_keepJustSummaryPrefixes(s) {
+ let matches = s.match(/^(\[[^\]]+\])+|^(\S+:\s*)+/);
+ if (matches == null) {
+ return '';
+ }
+
+ let prefix = matches[0];
+ if (prefix.substr(prefix.length - 1) != ' ') {
+ prefix += ' ';
+ }
+ return prefix;
+}
+
+/**
+ * An array of label <input>s that start with reserved prefixes.
+ */
+let TKR_labelsWithReservedPrefixes = [];
+
+/**
+ * An array of label <input>s that are equal to reserved words.
+ */
+let TKR_labelsConflictingWithReserved = [];
+
+/**
+ * An array of novel issue status values entered by the user on the
+ * current page. 'Novel' means that they are not well known and are
+ * likely to be typos. Note that this list will always have zero or
+ * one element, but a list is used for consistency with the list of
+ * novel labels.
+ */
+let TKR_novelStatuses = [];
+
+/**
+ * An array of novel issue label values entered by the user on the
+ * current page. 'Novel' means that they are not well known and are
+ * likely to be typos.
+ */
+let TKR_novelLabels = [];
+
+/**
+ * A boolean that indicates whether the entered owner value is valid or not.
+ */
+let TKR_invalidOwner = false;
+
+/**
+ * The user has changed the issue status text field. This function
+ * checks whether it is a well-known status value. If not, highlight it
+ * as a potential typo.
+ * @param {Element} textField The issue status text field.
+ * @return Always returns true to indicate that the browser should
+ * continue to process the user input event normally.
+ */
+function TKR_confirmNovelStatus(textField) {
+ let v = textField.value.trim().toLowerCase();
+ let isNovel = (v !== '');
+ let wellKnown = TKR_statusWords;
+ for (let i = 0; i < wellKnown.length && isNovel; ++i) {
+ let wk = wellKnown[i];
+ if (v == wk.toLowerCase()) {
+ isNovel = false;
+ }
+ }
+ if (isNovel) {
+ if (TKR_novelStatuses.indexOf(textField) == -1) {
+ TKR_novelStatuses.push(textField);
+ }
+ textField.classList.add(TKR_NOVEL_CLASS);
+ } else {
+ if (TKR_novelStatuses.indexOf(textField) != -1) {
+ TKR_novelStatuses.splice(TKR_novelStatuses.indexOf(textField), 1);
+ }
+ textField.classList.remove(TKR_NOVEL_CLASS);
+ }
+ TKR_updateConfirmBeforeSubmit();
+ return true;
+}
+
+
+/**
+ * The user has changed a issue label text field. This function checks
+ * whether it is a well-known label value. If not, highlight it as a
+ * potential typo.
+ * @param {Element} textField An issue label text field.
+ * @return Always returns true to indicate that the browser should
+ * continue to process the user input event normally.
+ *
+ * TODO(jrobbins): code duplication with function above.
+ */
+function TKR_confirmNovelLabel(textField) {
+ let v = textField.value.trim().toLowerCase();
+ if (v.search('-') == 0) {
+ v = v.substr(1);
+ }
+ let isNovel = (v !== '');
+ if (v.indexOf('?') > -1) {
+ isNovel = false; // We don't count labels that the user must edit anyway.
+ }
+ let wellKnown = TKR_labelWords;
+ for (var i = 0; i < wellKnown.length && isNovel; ++i) {
+ let wk = wellKnown[i];
+ if (v == wk.toLowerCase()) {
+ isNovel = false;
+ }
+ }
+
+ let containsReservedPrefix = false;
+ var textFieldWarningDisplayed = TKR_labelsWithReservedPrefixes.indexOf(textField) != -1;
+ for (var i = 0; i < TKR_LABEL_RESERVED_PREFIXES.length; ++i) {
+ if (v.startsWith(TKR_LABEL_RESERVED_PREFIXES[i] + '-')) {
+ if (!textFieldWarningDisplayed) {
+ TKR_labelsWithReservedPrefixes.push(textField);
+ }
+ containsReservedPrefix = true;
+ break;
+ }
+ }
+ if (!containsReservedPrefix && textFieldWarningDisplayed) {
+ TKR_labelsWithReservedPrefixes.splice(
+ TKR_labelsWithReservedPrefixes.indexOf(textField), 1);
+ }
+
+ let conflictsWithReserved = false;
+ var textFieldWarningDisplayed =
+ TKR_labelsConflictingWithReserved.indexOf(textField) != -1;
+ for (var i = 0; i < TKR_LABEL_RESERVED_PREFIXES.length; ++i) {
+ if (v == TKR_LABEL_RESERVED_PREFIXES[i]) {
+ if (!textFieldWarningDisplayed) {
+ TKR_labelsConflictingWithReserved.push(textField);
+ }
+ conflictsWithReserved = true;
+ break;
+ }
+ }
+ if (!conflictsWithReserved && textFieldWarningDisplayed) {
+ TKR_labelsConflictingWithReserved.splice(
+ TKR_labelsConflictingWithReserved.indexOf(textField), 1);
+ }
+
+ if (isNovel) {
+ if (TKR_novelLabels.indexOf(textField) == -1) {
+ TKR_novelLabels.push(textField);
+ }
+ textField.classList.add(TKR_NOVEL_CLASS);
+ } else {
+ if (TKR_novelLabels.indexOf(textField) != -1) {
+ TKR_novelLabels.splice(TKR_novelLabels.indexOf(textField), 1);
+ }
+ textField.classList.remove(TKR_NOVEL_CLASS);
+ }
+ TKR_updateConfirmBeforeSubmit();
+ return true;
+}
+
+/**
+ * Dictionary { prefix:[textField,...], ...} for all the prefixes of any
+ * text that has been entered into any label field. This is used to find
+ * duplicate labels and multiple labels that share an single exclusive
+ * prefix (e.g., Priority).
+ */
+let TKR_usedPrefixes = {};
+
+/**
+ * This is a prefix to the HTML ids of each label editing field.
+ * It varied by page, so it is set in the HTML page. Needed to initialize
+ * our validation across label input text fields.
+ */
+let TKR_labelFieldIDPrefix = '';
+
+/**
+ * Initialize the set of all used labels on forms that allow users to
+ * enter issue labels. Some labels are supplied in the HTML page
+ * itself, and we do not want to offer duplicates of those.
+ */
+function TKR_prepLabelAC() {
+ let i = 0;
+ while ($('label'+i)) {
+ TKR_validateLabel($('label'+i));
+ i++;
+ }
+}
+
+/**
+ * Reads the owner field and determines if the current value is a valid member.
+ */
+function TKR_prepOwnerField(validOwners) {
+ if ($('owneredit')) {
+ currentOwner = $('owneredit').value;
+ if (currentOwner == '') {
+ // Empty owner field is not an invalid owner.
+ invalidOwner = false;
+ return;
+ }
+ invalidOwner = true;
+ for (let i = 0; i < validOwners.length; i++) {
+ let owner = validOwners[i].name;
+ if (currentOwner == owner) {
+ invalidOwner = false;
+ break;
+ }
+ }
+ TKR_invalidOwner = invalidOwner;
+ }
+}
+
+/**
+ * Keep track of which label prefixes have been used so that
+ * we can not offer the same label twice and so that we can highlight
+ * multiple labels that share an exclusive prefix.
+ */
+function TKR_updateUsedPrefixes(textField) {
+ if (textField.oldPrefix != undefined) {
+ DeleteArrayElement(TKR_usedPrefixes[textField.oldPrefix], textField);
+ }
+
+ let prefix = textField.value.split('-')[0].toLowerCase();
+ if (TKR_usedPrefixes[prefix] == undefined) {
+ TKR_usedPrefixes[prefix] = [textField];
+ } else {
+ TKR_usedPrefixes[prefix].push(textField);
+ }
+ textField.oldPrefix = prefix;
+}
+
+/**
+ * Go through all the label entry fields in our prefix-oriented
+ * data structure and highlight any that are part of a conflict
+ * (multiple labels with the same exclusive prefix). Unhighlight
+ * any label text entry fields that are not in conflict. And, display
+ * a warning message to encourage the user to correct the conflict.
+ */
+function TKR_highlightExclusiveLabelPrefixConflicts() {
+ let conflicts = [];
+ for (let prefix in TKR_usedPrefixes) {
+ let textFields = TKR_usedPrefixes[prefix];
+ if (textFields == undefined || textFields.length == 0) {
+ delete TKR_usedPrefixes[prefix];
+ } else if (textFields.length > 1 &&
+ FindInArray(TKR_exclPrefixes, prefix) != -1) {
+ conflicts.push(prefix);
+ for (var i = 0; i < textFields.length; i++) {
+ var tf = textFields[i];
+ tf.classList.add(TKR_EXCL_CONFICT_CLASS);
+ }
+ } else {
+ for (var i = 0; i < textFields.length; i++) {
+ var tf = textFields[i];
+ tf.classList.remove(TKR_EXCL_CONFICT_CLASS);
+ }
+ }
+ }
+ if (conflicts.length > 0) {
+ let severity = TKR_restrict_to_known ? 'Error' : 'Warning';
+ let confirm_area = $(TKR_CONFIRMAREA_ID);
+ if (confirm_area) {
+ $('confirmmsg').textContent = (severity +
+ ': Multiple values for: ' + conflicts.join(', '));
+ confirm_area.className = TKR_EXCL_CONFICT_CLASS;
+ confirm_area.style.display = '';
+ }
+ }
+}
+
+/**
+ * Keeps track of any label text fields that have a value that
+ * is bad enough to prevent submission of the form. When this
+ * list is non-empty, the submit button gets disabled.
+ */
+let TKR_labelsBlockingSubmit = [];
+
+/**
+ * Look for any "?" characters in the label and, if found,
+ * make the label text red, prevent form submission, and
+ * display on-page help to tell the user to edit those labels.
+ * @param {Element} textField An issue label text field.
+ */
+function TKR_highlightQuestionMarks(textField) {
+ let tfIndex = TKR_labelsBlockingSubmit.indexOf(textField);
+ if (textField.value.indexOf('?') > -1 && tfIndex == -1) {
+ TKR_labelsBlockingSubmit.push(textField);
+ textField.classList.add(TKR_QUESTION_MARK_CLASS);
+ } else if (textField.value.indexOf('?') == -1 && tfIndex > -1) {
+ TKR_labelsBlockingSubmit.splice(tfIndex, 1);
+ textField.classList.remove(TKR_QUESTION_MARK_CLASS);
+ }
+
+ let block_submit_msg = $('blocksubmitmsg');
+ if (block_submit_msg) {
+ if (TKR_labelsBlockingSubmit.length > 0) {
+ block_submit_msg.textContent = 'You must edit labels that contain "?".';
+ } else {
+ block_submit_msg.textContent = '';
+ }
+ }
+}
+
+/**
+ * The user has edited a label. Display a warning if the label is
+ * not a well known label, or if there are multiple labels that
+ * share an exclusive prefix.
+ * @param {Element} textField An issue label text field.
+ */
+function TKR_validateLabel(textField) {
+ if (textField == undefined) return;
+ TKR_confirmNovelLabel(textField);
+ TKR_updateUsedPrefixes(textField);
+ TKR_highlightExclusiveLabelPrefixConflicts();
+ TKR_highlightQuestionMarks(textField);
+}
+
+// TODO(jrobbins): what about typos in owner and cc list?
+
+/**
+ * If there are any novel status or label values, we display a message
+ * that explains that to the user so that they can catch any typos before
+ * submitting them. If the project is restricting input to only the
+ * well-known statuses and labels, then show these as an error instead.
+ * In that case, on-page JS will prevent submission.
+ */
+function TKR_updateConfirmBeforeSubmit() {
+ let severity = TKR_restrict_to_known ? 'Error' : 'Note';
+ let novelWord = TKR_restrict_to_known ? 'undefined' : 'uncommon';
+ let msg = '';
+ let labels = TKR_novelLabels.map(function(item) {
+ return item.value;
+ });
+ if (TKR_novelStatuses.length > 0 && TKR_novelLabels.length > 0) {
+ msg = severity + ': You are using an ' + novelWord + ' status and ' + novelWord + ' label(s): ' + labels.join(', ') + '.'; // TODO: i18n
+ } else if (TKR_novelStatuses.length > 0) {
+ msg = severity + ': You are using an ' + novelWord + ' status value.';
+ } else if (TKR_novelLabels.length > 0) {
+ msg = severity + ': You are using ' + novelWord + ' label(s): ' + labels.join(', ') + '.';
+ }
+
+ for (var i = 0; i < TKR_labelsWithReservedPrefixes.length; ++i) {
+ msg += '\nNote: The label ' + TKR_labelsWithReservedPrefixes[i].value +
+ ' starts with a reserved word. This is not recommended.';
+ }
+ for (var i = 0; i < TKR_labelsConflictingWithReserved.length; ++i) {
+ msg += '\nNote: The label ' + TKR_labelsConflictingWithReserved[i].value +
+ ' conflicts with a reserved word. This is not recommended.';
+ }
+ // Display the owner is no longer a member note only if an owner error is not
+ // already shown on the page.
+ if (TKR_invalidOwner && !$('ownererror')) {
+ msg += '\nNote: Current owner is no longer a project member.';
+ }
+
+ let confirm_area = $(TKR_CONFIRMAREA_ID);
+ if (confirm_area) {
+ $('confirmmsg').textContent = msg;
+ if (msg != '') {
+ confirm_area.className = TKR_NOVEL_CLASS;
+ confirm_area.style.display = '';
+ } else {
+ confirm_area.style.display = 'none';
+ }
+ }
+}
+
+
+/**
+ * The user has selected a command from the 'Actions...' menu
+ * on the issue list. This function checks the selected value and carry
+ * out the requested action.
+ * @param {Element} actionsMenu The 'Actions...' <select> form element.
+ */
+function TKR_handleListActions(actionsMenu) {
+ switch (actionsMenu.value) {
+ case 'bulk':
+ TKR_HandleBulkEdit();
+ break;
+ case 'colspec':
+ TKR_closeAllPopups(actionsMenu);
+ _showID('columnspec');
+ _hideID('addissuesspec');
+ break;
+ case 'flagspam':
+ TKR_flagSpam(true);
+ break;
+ case 'unflagspam':
+ TKR_flagSpam(false);
+ break;
+ case 'addtohotlist':
+ TKR_addToHotlist();
+ break;
+ case 'addissues':
+ _showID('addissuesspec');
+ _hideID('columnspec');
+ setCurrentColSpec();
+ break;
+ case 'removeissues':
+ HTL_removeIssues();
+ break;
+ case 'issuesperpage':
+ break;
+ }
+ actionsMenu.value = 'moreactions';
+}
+
+
+async function TKR_handleDetailActions(localId) {
+ let moreActions = $('more_actions');
+
+ if (moreActions.value == 'delete') {
+ $('copy_issue_form_fragment').style.display = 'none';
+ $('move_issue_form_fragment').style.display = 'none';
+ let ok = confirm(
+ 'Normally, you should just close issues by setting their status ' +
+ 'to a closed value.\n' +
+ 'Are you sure you want to delete this issue?');
+ if (ok) {
+ await window.prpcClient.call('monorail.Issues', 'DeleteIssue', {
+ issueRef: {
+ projectName: window.CS_env.projectName,
+ localId: localId,
+ },
+ delete: true,
+ });
+ location.reload(true);
+ return;
+ }
+ }
+
+ if (moreActions.value == 'move') {
+ $('move_issue_form_fragment').style.display = '';
+ $('copy_issue_form_fragment').style.display = 'none';
+ return;
+ }
+ if (moreActions.value == 'copy') {
+ $('copy_issue_form_fragment').style.display = '';
+ $('move_issue_form_fragment').style.display = 'none';
+ return;
+ }
+
+ // If no action was taken, reset the dropdown to the 'More actions...' item.
+ moreActions.value = '0';
+}
+
+/**
+ * The user has selected the "Flag as spam..." menu item.
+ */
+async function TKR_flagSpam(isSpam) {
+ const selectedIssueRefs = [];
+ issueRefs.forEach((issueRef) => {
+ const checkbox = $('cb_' + issueRef.id);
+ if (checkbox && checkbox.checked) {
+ selectedIssueRefs.push({
+ projectName: issueRef.project_name,
+ localId: issueRef.id,
+ });
+ }
+ });
+ if (selectedIssueRefs.length > 0) {
+ if (!confirm((isSpam ? 'Flag' : 'Un-flag') +
+ ' all selected issues as spam?')) {
+ return;
+ }
+ await window.prpcClient.call('monorail.Issues', 'FlagIssues', {
+ issueRefs: selectedIssueRefs,
+ flag: isSpam,
+ });
+ location.reload(true);
+ } else {
+ alert('Please select some issues to flag as spam');
+ }
+}
+
+function TKR_addToHotlist() {
+ const selectedIssueRefs = GetSelectedIssuesRefs();
+ if (selectedIssueRefs.length > 0) {
+ window.__hotlists_dialog.ShowUpdateHotlistDialog();
+ } else {
+ alert('Please select some issues to add to a hotlist');
+ }
+}
+
+
+function GetSelectedIssuesRefs() {
+ let selectedIssueRefs = [];
+ for (let i = 0; i < issueRefs.length; i++) {
+ let checkbox = document.getElementById('cb_' + issueRefs[i]['id']);
+ if (checkbox == null) {
+ checkbox = document.getElementById(
+ 'cb_' + issueRefs[i]['project_name'] + ':' + issueRefs[i]['id']);
+ }
+ if (checkbox && checkbox.checked) {
+ selectedIssueRefs.push(issueRefs[i]);
+ }
+ }
+ return selectedIssueRefs;
+}
+
+function onResponseUpdateUI(modifiedHotlists, remainingHotlists) {
+ const list = $('user-hotlists-list');
+ while (list.firstChild) {
+ list.removeChild(list.firstChild);
+ }
+ remainingHotlists.forEach((hotlist) => {
+ const name = hotlist[0];
+ const userId = hotlist[1];
+ const url = `/u/${userId}/hotlists/${name}`;
+ const hotlistLink = document.createElement('a');
+ hotlistLink.setAttribute('href', url);
+ hotlistLink.textContent = name;
+ list.appendChild(hotlistLink);
+ list.appendChild(document.createElement('br'));
+ });
+ $('user-hotlists').style.display = 'block';
+ onAddIssuesResponse(modifiedHotlists);
+}
+
+function onAddIssuesResponse(modifiedHotlists) {
+ const hotlistNames = modifiedHotlists.map((hotlist) => hotlist[0]).join(', ');
+ $('notice').textContent = 'Successfully updated ' + hotlistNames;
+ $('update-issues-hotlists').style.display = 'none';
+ $('alert-table').style.display = 'table';
+}
+
+function onAddIssuesFailure(reason) {
+ $('notice').textContent =
+ 'Some hotlists were not updated: ' + reason.description;
+ $('update-issues-hotlists').style.display = 'none';
+ $('alert-table').style.display = 'table';
+}
+
+/**
+ * The user has selected the "Bulk Edit..." menu item. Go to a page that
+ * offers the ability to edit all selected issues.
+ */
+// TODO(jrobbins): cross-project bulk edit
+function TKR_HandleBulkEdit() {
+ let selectedIssueRefs = GetSelectedIssuesRefs();
+ let selectedLocalIDs = [];
+ for (let i = 0; i < selectedIssueRefs.length; i++) {
+ selectedLocalIDs.push(selectedIssueRefs[i]['id']);
+ }
+ if (selectedLocalIDs.length > 0) {
+ let selectedLocalIDString = selectedLocalIDs.join(',');
+ let url = 'bulkedit?ids=' + selectedLocalIDString;
+ TKR_go(url + _ctxArgs);
+ } else {
+ alert('Please select some issues to edit');
+ }
+}
+
+/**
+ * Clears the selected status value when the 'clear' operator is chosen.
+ */
+function TKR_ignoreWidgetIfOpIsClear(selectEl, inputID) {
+ if (selectEl.value == 'clear') {
+ document.getElementById(inputID).value = '';
+ }
+}
+
+/**
+ * Array of original labels on the served page, so that we can notice
+ * when the used submits a form that has any Restrict-* labels removed.
+ */
+let TKR_allOrigLabels = [];
+
+
+/**
+ * Prevent users from easily entering "+1" comments.
+ */
+function TKR_checkPlusOne() {
+ let c = $('addCommentTextArea').value;
+ let instructions = (
+ '\nPlease use the star icon instead.\n' +
+ 'Stars show your interest without annoying other users.');
+ if (new RegExp('^\\s*[-+]+[0-9]+\\s*.{0,30}$', 'm').test(c) &&
+ c.length < 150) {
+ alert('This looks like a "+1" comment.' + instructions);
+ return false;
+ }
+ if (new RegExp('^\\s*me too.{0,30}$', 'i').test(c)) {
+ alert('This looks like a "me too" comment.' + instructions);
+ return false;
+ }
+ return true;
+}
+
+
+/**
+ * If the user removes Restrict-* labels, ask them if they are sure.
+ */
+function TKR_checkUnrestrict(prevent_restriction_removal) {
+ let removedRestrictions = [];
+
+ for (let i = 0; i < TKR_allOrigLabels.length; ++i) {
+ let origLabel = TKR_allOrigLabels[i];
+ if (origLabel.indexOf('Restrict-') == 0) {
+ let found = false;
+ let j = 0;
+ while ($('label' + j)) {
+ let newLabel = $('label' + j).value;
+ if (newLabel == origLabel) {
+ found = true;
+ break;
+ }
+ j++;
+ }
+ if (!found) {
+ removedRestrictions.push(origLabel);
+ }
+ }
+ }
+
+ if (removedRestrictions.length == 0) {
+ return true;
+ }
+
+ if (prevent_restriction_removal) {
+ let msg = 'You may not remove restriction labels.';
+ alert(msg);
+ return false;
+ }
+
+ let instructions = (
+ 'You are removing these restrictions:\n ' +
+ removedRestrictions.join('\n ') +
+ '\nThis may allow more people to access this issue.' +
+ '\nAre you sure?');
+ return confirm(instructions);
+}
+
+
+/**
+ * Add a column to a list view by updating the colspec form element and
+ * submiting an invisible <form> to load a new page that includes the column.
+ * @param {string} colname The name of the column to start showing.
+ */
+function TKR_addColumn(colname) {
+ let colspec = TKR_getColspecElement();
+ colspec.value = colspec.value + ' ' + colname;
+ $('colspecform').submit();
+}
+
+
+/**
+ * Allow members to shift-click to select multiple issues. This keeps
+ * track of the last row that the user clicked a checkbox on.
+ */
+let TKR_lastSelectedRow = undefined;
+
+
+/**
+ * Return true if an event had the shift-key pressed.
+ * @param {Event} evt The mouse click event.
+ */
+function TKR_hasShiftKey(evt) {
+ evt = (evt) ? evt : (window.event) ? window.event : '';
+ if (evt) {
+ if (evt.modifiers) {
+ return evt.modifiers & Event.SHIFT_MASK;
+ } else {
+ return evt.shiftKey;
+ }
+ }
+ return false;
+}
+
+
+/**
+ * Select one row: check the checkbox and use highlight color.
+ * @param {Element} row the row containing the checkbox that the user clicked.
+ * @param {boolean} checked True if the user checked the box.
+ */
+function TKR_rangeSelectRow(row, checked) {
+ if (!row) {
+ return;
+ }
+ if (checked) {
+ row.classList.add('selected');
+ } else {
+ row.classList.remove('selected');
+ }
+
+ let td = row.firstChild;
+ while (td && td.tagName != 'TD') {
+ td = td.nextSibling;
+ }
+ if (!td) {
+ return;
+ }
+
+ let checkbox = td.firstChild;
+ while (checkbox && checkbox.tagName != 'INPUT') {
+ checkbox = checkbox.nextSibling;
+ }
+ if (!checkbox) {
+ return;
+ }
+
+ checkbox.checked = checked;
+}
+
+
+/**
+ * If the user shift-clicked a checkbox, (un)select a range.
+ * @param {Event} evt The mouse click event.
+ * @param {Element} el The checkbox that was clicked.
+ */
+function TKR_checkRangeSelect(evt, el) {
+ let clicked_row = el.parentNode.parentNode.rowIndex;
+ if (clicked_row == TKR_lastSelectedRow) {
+ return;
+ }
+ if (TKR_hasShiftKey(evt) && TKR_lastSelectedRow != undefined) {
+ let results_table = $('resultstable');
+ let delta = (clicked_row > TKR_lastSelectedRow) ? 1 : -1;
+ for (let i = TKR_lastSelectedRow; i != clicked_row; i += delta) {
+ TKR_rangeSelectRow(results_table.rows[i], el.checked);
+ }
+ }
+ TKR_lastSelectedRow = clicked_row;
+}
+
+
+/**
+ * Make a link to a given issue that includes context parameters that allow
+ * the user to see the same list columns, sorting, query, and pagination state
+ * if they ever navigate up to the list again.
+ * @param {{issue_url: string}} issueRef The dict with info about an issue,
+ * including a url to the issue detail page.
+ */
+function TKR_makeIssueLink(issueRef) {
+ return '/p/' + issueRef['project_name'] + '/issues/detail?id=' + issueRef['id'] + _ctxArgs;
+}
+
+
+/**
+ * Hide or show a list column in the case where we already have the
+ * data for that column on the page.
+ * @param {number} colIndex index of the column that is being shown or hidden.
+ */
+function TKR_toggleColumnUpdate(colIndex) {
+ let shownCols = TKR_getColspecElement().value.split(' ');
+ let filteredCols = [];
+ for (let i=0; i< shownCols.length; i++) {
+ if (_allColumnNames[colIndex] != shownCols[i].toLowerCase()) {
+ filteredCols.push(shownCols[i]);
+ }
+ }
+
+ TKR_getColspecElement().value = filteredCols.join(' ');
+ TKR_toggleColumn('hide_col_' + colIndex);
+ _ctxArgs = _formatContextQueryArgs();
+ window.history.replaceState({}, '', '?' + _ctxArgs);
+}
+
+
+/**
+ * Convert a column into a groupby clause by removing it from the column spec
+ * and adding it to the groupby spec, then reloading the page.
+ * @param {number} colIndex index of the column that is being shown or hidden.
+ */
+function TKR_addGroupBy(colIndex) {
+ let colName = _allColumnNames[colIndex];
+ let shownCols = TKR_getColspecElement().value.split(' ');
+ let filteredCols = [];
+ for (var i=0; i < shownCols.length; i++) {
+ if (shownCols[i] && colName != shownCols[i].toLowerCase()) {
+ filteredCols.push(shownCols[i]);
+ }
+ }
+
+ TKR_getColspecElement().value = filteredCols.join(' ');
+
+ let groupSpec = $('groupbyspec');
+ let shownGroupings = groupSpec.value.split(' ');
+ let filteredGroupings = [];
+ for (i=0; i < shownGroupings.length; i++) {
+ if (shownGroupings[i] && colName != shownGroupings[i].toLowerCase()) {
+ filteredGroupings.push(shownGroupings[i]);
+ }
+ }
+ filteredGroupings.push(colName);
+ groupSpec.value = filteredGroupings.join(' ');
+ $('colspecform').submit();
+}
+
+
+/**
+ * Add a multi-valued custom field editing widget.
+ */
+function TKR_addMultiFieldValueWidget(
+ el, field_id, field_type, opt_validate_1, opt_validate_2, field_phase_name) {
+ let widget = document.createElement('INPUT');
+ widget.name = (field_phase_name && (
+ field_phase_name != '')) ? `custom_${field_id}_${field_phase_name}` :
+ `custom_${field_id}`;
+ if (field_type == 'str' || field_type =='url') {
+ widget.size = 90;
+ }
+ if (field_type == 'user') {
+ widget.style = 'width:12em';
+ widget.classList.add('userautocomplete');
+ widget.classList.add('customfield');
+ widget.classList.add('multivalued');
+ widget.addEventListener('focus', function(event) {
+ _acrob(null);
+ _acof(event);
+ });
+ }
+ if (field_type == 'int' || field_type == 'date') {
+ widget.style.textAlign = 'right';
+ widget.style.width = '12em';
+ widget.min = opt_validate_1;
+ widget.max = opt_validate_2;
+ }
+ if (field_type == 'int') {
+ widget.type = 'number';
+ } else if (field_type == 'date') {
+ widget.type = 'date';
+ }
+
+ el.parentNode.insertBefore(widget, el);
+
+ let del_button = document.createElement('U');
+ del_button.onclick = function(event) {
+ _removeMultiFieldValueWidget(event.target);
+ };
+ del_button.textContent = 'X';
+ el.parentNode.insertBefore(del_button, el);
+}
+
+
+function TKR_removeMultiFieldValueWidget(el) {
+ let target = el.previousSibling;
+ while (target && target.tagName != 'INPUT') {
+ target = target.previousSibling;
+ }
+ if (target) {
+ el.parentNode.removeChild(target);
+ }
+ el.parentNode.removeChild(el); // the X itself
+}
+
+
+/**
+ * Trim trailing commas and spaces off <INPUT type="email" multiple> fields
+ * before submitting the form.
+ */
+function TKR_trimCommas() {
+ let ccField = $('memberccedit');
+ if (ccField) {
+ ccField.value = ccField.value.replace(/,\s*$/, '');
+ }
+ ccField = $('memberenter');
+ if (ccField) {
+ ccField.value = ccField.value.replace(/,\s*$/, '');
+ }
+}
+
+
+/**
+ * Identify which issues have been checkedboxed for removal from hotlist.
+ */
+function HTL_removeIssues() {
+ let selectedLocalIDs = [];
+ for (let i = 0; i < issueRefs.length; i++) {
+ issueRef = issueRefs[i]['project_name']+':'+issueRefs[i]['id'];
+ let checkbox = document.getElementById('cb_' + issueRef);
+ if (checkbox && checkbox.checked) {
+ selectedLocalIDs.push(issueRef);
+ }
+ }
+
+ if (selectedLocalIDs.length > 0) {
+ if (!confirm('Remove all selected issues?')) {
+ return;
+ }
+ let selectedLocalIDString = selectedLocalIDs.join(',');
+ $('bulk_remove_local_ids').value = selectedLocalIDString;
+ $('bulk_remove_value').value = 'true';
+ setCurrentColSpec();
+
+ let form = $('bulkremoveissues');
+ form.submit();
+ } else {
+ alert('Please select some issues to remove');
+ }
+}
+
+function setCurrentColSpec() {
+ $('current_col_spec').value = TKR_getColspecElement().value;
+}
+
+
+async function saveNote(textBox, hotlistID) {
+ const projectName = textBox.getAttribute('projectname');
+ const localId = textBox.getAttribute('localid');
+ await window.prpcClient.call(
+ 'monorail.Features', 'UpdateHotlistIssueNote', {
+ hotlistRef: {
+ hotlistId: hotlistID,
+ },
+ issueRef: {
+ projectName: textBox.getAttribute('projectname'),
+ localId: textBox.getAttribute('localid'),
+ },
+ note: textBox.value,
+ });
+ $(`itemnote_${projectName}_${localId}`).value = textBox.value;
+}
+
+// TODO(jojwang): monorail:4291, integrate this into autocomplete process
+// to prevent calling ListStatuses twice.
+/**
+ * Load the status select element with possible project statuses.
+ */
+function TKR_loadStatusSelect(projectName, selectId, selected, isBulkEdit=false) {
+ const projectRequestMessage = {
+ project_name: projectName};
+ const statusesPromise = window.prpcClient.call(
+ 'monorail.Projects', 'ListStatuses', projectRequestMessage);
+ statusesPromise.then((statusesResponse) => {
+ const jsonData = TKR_convertStatuses(statusesResponse);
+ const statusSelect = document.getElementById(selectId);
+ // An initial option with value='selected' had to be added in HTML
+ // to prevent TKR_isDirty() from registering a change in the select input
+ // even when the user has not selected a different value.
+ // That option needs to be removed otherwise, screenreaders will announce
+ // its existence.
+ while (statusSelect.firstChild) {
+ statusSelect.removeChild(statusSelect.firstChild);
+ }
+ // Add unrecognized status (can be empty status) to open statuses.
+ let selectedFound = false;
+ jsonData.open.concat(jsonData.closed).forEach((status) => {
+ if (status.name === selected) {
+ selectedFound = true;
+ }
+ });
+ if (!selectedFound) {
+ jsonData.open.unshift({name: selected});
+ }
+ // Add open statuses.
+ if (jsonData.open.length > 0) {
+ const openGroup =
+ statusSelect.appendChild(createStatusGroup('Open', jsonData.open, selected, isBulkEdit));
+ }
+ if (jsonData.closed.length > 0) {
+ statusSelect.appendChild(createStatusGroup('Closed', jsonData.closed, selected));
+ }
+ });
+}
+
+function createStatusGroup(groupName, options, selected, isBulkEdit=false) {
+ const groupElement = document.createElement('optgroup');
+ groupElement.label = groupName;
+ options.forEach((option) => {
+ const opt = document.createElement('option');
+ opt.value = option.name;
+ opt.selected = (selected === option.name) ? true : false;
+ // Special case for when opt represents an empty status.
+ if (opt.value === '') {
+ if (isBulkEdit) {
+ opt.textContent = '--- (no change)';
+ opt.setAttribute('aria-label', 'no change');
+ } else {
+ opt.textContent = '--- (empty status)';
+ opt.setAttribute('aria-label', 'empty status');
+ }
+ } else {
+ opt.textContent = option.doc ? `${option.name} = ${option.doc}` : option.name;
+ }
+ groupElement.appendChild(opt);
+ });
+ return groupElement;
+}
+
+/**
+ * Generate DOM for a filter rules preview section.
+ */
+function renderFilterRulesSection(section_id, heading, value_why_list) {
+ let section = $(section_id);
+ while (section.firstChild) {
+ section.removeChild(section.firstChild);
+ }
+ if (value_why_list.length == 0) return false;
+
+ section.appendChild(document.createTextNode(heading + ': '));
+ for (let i = 0; i < value_why_list.length; ++i) {
+ if (i > 0) {
+ section.appendChild(document.createTextNode(', '));
+ }
+ let value = value_why_list[i].value;
+ let why = value_why_list[i].why;
+ let span = section.appendChild(
+ document.createElement('span'));
+ span.textContent = value;
+ if (why) span.setAttribute('title', why);
+ }
+ return true;
+}
+
+
+/**
+ * Generate DOM for a filter rules preview section bullet list.
+ */
+function renderFilterRulesListSection(section_id, heading, value_why_list) {
+ let section = $(section_id);
+ while (section.firstChild) {
+ section.removeChild(section.firstChild);
+ }
+ if (value_why_list.length == 0) return false;
+
+ section.appendChild(document.createTextNode(heading + ': '));
+ let bulletList = document.createElement('ul');
+ section.appendChild(bulletList);
+ for (let i = 0; i < value_why_list.length; ++i) {
+ let listItem = document.createElement('li');
+ bulletList.appendChild(listItem);
+ let value = value_why_list[i].value;
+ let why = value_why_list[i].why;
+ let span = listItem.appendChild(
+ document.createElement('span'));
+ span.textContent = value;
+ if (why) span.setAttribute('title', why);
+ }
+ return true;
+}
+
+
+/**
+ * Ask server to do a presubmit check and then display and warnings
+ * as the user edits an issue.
+ */
+function TKR_presubmit() {
+ const issue_form = (
+ document.forms.create_issue_form || document.forms.issue_update_form);
+ if (!issue_form) {
+ return;
+ }
+
+ const inputs = issue_form.querySelectorAll(
+ 'input:not([type="file"]), textarea, select');
+ if (!inputs) {
+ return;
+ }
+
+ const valuesByName = new Map();
+ for (const key in inputs) {
+ if (!inputs.hasOwnProperty(key)) {
+ continue;
+ }
+ const input = inputs[key];
+ if (input.type === 'checkbox' && !input.checked) {
+ continue;
+ }
+ if (!valuesByName.has(input.name)) {
+ valuesByName.set(input.name, []);
+ }
+ valuesByName.get(input.name).push(input.value);
+ }
+
+ const issueDelta = TKR_buildIssueDelta(valuesByName);
+ const issueRef = {project_name: window.CS_env.projectName};
+ if (valuesByName.has('id')) {
+ issueRef.local_id = valuesByName.get('id')[0];
+ }
+
+ const presubmitMessage = {
+ issue_ref: issueRef,
+ issue_delta: issueDelta,
+ };
+ const presubmitPromise = window.prpcClient.call(
+ 'monorail.Issues', 'PresubmitIssue', presubmitMessage);
+
+ presubmitPromise.then((response) => {
+ $('owner_avail_state').style.display = (
+ response.ownerAvailabilityState ? '' : 'none');
+ $('owner_avail_state').className = (
+ 'availability_' + response.ownerAvailabilityState);
+ $('owner_availability').textContent = response.ownerAvailability;
+
+ let derived_labels;
+ if (response.derivedLabels) {
+ derived_labels = renderFilterRulesSection(
+ 'preview_filterrules_labels', 'Labels', response.derivedLabels);
+ }
+ let derived_owner_email;
+ if (response.derivedOwners) {
+ derived_owner_email = renderFilterRulesSection(
+ 'preview_filterrules_owner', 'Owner', response.derivedOwners[0]);
+ }
+ let derived_cc_emails;
+ if (response.derivedCcs) {
+ derived_cc_emails = renderFilterRulesSection(
+ 'preview_filterrules_ccs', 'Cc', response.derivedCcs);
+ }
+ let warnings;
+ if (response.warnings) {
+ warnings = renderFilterRulesListSection(
+ 'preview_filterrules_warnings', 'Warnings', response.warnings);
+ }
+ let errors;
+ if (response.errors) {
+ errors = renderFilterRulesListSection(
+ 'preview_filterrules_errors', 'Errors', response.errors);
+ }
+
+ if (derived_labels || derived_owner_email || derived_cc_emails ||
+ warnings || errors) {
+ $('preview_filterrules_area').style.display = '';
+ } else {
+ $('preview_filterrules_area').style.display = 'none';
+ }
+ });
+}
+
+function HTL_deleteHotlist(form) {
+ if (confirm('Are you sure you want to delete this hotlist? This cannot be undone.')) {
+ $('delete').value = 'true';
+ form.submit();
+ }
+}
+
+function HTL_toggleIssuesShown(toggleIssuesButton) {
+ const can = toggleIssuesButton.value;
+ const hotlist_name = $('hotlist_name').value;
+ let url = `${hotlist_name}?can=${can}`;
+ const hidden_cols = $('colcontrol').classList.value;
+ if (window.location.href.includes('&colspec') || hidden_cols) {
+ const colSpecElement =
+ TKR_getColspecElement(); // eslint-disable-line new-cap
+ let sort = '';
+ if ($('sort')) {
+ sort = $('sort').value.split(' ').join('+');
+ url += `&sort=${sort}`;
+ }
+ url += colSpecElement ? `&colspec=${colSpecElement.value}` : '';
+ }
+ TKR_go(url);
+}
diff --git a/static/js/tracker/tracker-fields.js b/static/js/tracker/tracker-fields.js
new file mode 100644
index 0000000..d84f11d
--- /dev/null
+++ b/static/js/tracker/tracker-fields.js
@@ -0,0 +1,75 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS code for editing fields and field definitions.
+ */
+
+var TKR_fieldNameXmlHttp;
+
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName Current project name.
+ * @param {string} fieldName The proposed field name.
+ */
+async function TKR_checkFieldNameOnServer(projectName, fieldName) {
+ fieldName = fieldName.toLowerCase();
+
+ const fieldNameMessage = {
+ project_name: projectName,
+ field_name: fieldName,
+ };
+ const labelOptionsMessage = {
+ project_name: projectName,
+ };
+ const responses = await Promise.all([
+ window.prpcClient.call(
+ 'monorail.Projects', 'CheckFieldName', fieldNameMessage),
+ window.prpcClient.call(
+ 'monorail.Projects', 'GetLabelOptions', labelOptionsMessage),
+ ]);
+
+ const fieldNameResponse = responses[0];
+ const labelsResponse = responses[1];
+
+ $('fieldnamefeedback').textContent = fieldNameResponse.error || '';
+ $('submit_btn').disabled = fieldNameResponse.error ? 'disabled' : '';
+
+ const maskedLabels = (labelsResponse.labelOptions || []).filter(
+ label_def => label_def.label.toLowerCase().startsWith(fieldName + '-'));
+
+ if (maskedLabels.length === 0) {
+ enableOtherTypeOptions(false);
+ } else {
+ const prefixLength = fieldName.length + 1;
+ const padLength = Math.max.apply(null, maskedLabels.map(
+ label_def => label_def.label.length - prefixLength));
+ const choicesLines = maskedLabels.map(label_def => {
+ // Strip the field name from the label.
+ const choice = label_def.label.substr(prefixLength);
+ return choice.padEnd(padLength) + ' = ' + label_def.docstring;
+ });
+ $('choices').textContent = choicesLines.join('\n');
+ $('field_type').value = 'enum_type';
+ $('choices_row').style.display = '';
+ enableOtherTypeOptions(true);
+ }
+}
+
+
+function enableOtherTypeOptions(disabled) {
+ let type_option_el = $('field_type').firstChild;
+ while (type_option_el) {
+ if (type_option_el.tagName == 'OPTION') {
+ if (type_option_el.value != 'enum_type') {
+ type_option_el.disabled = disabled ? 'disabled' : '';
+ }
+ }
+ type_option_el = type_option_el.nextSibling;
+ }
+}
diff --git a/static/js/tracker/tracker-install-ac.js b/static/js/tracker/tracker-install-ac.js
new file mode 100644
index 0000000..2fe1dcd
--- /dev/null
+++ b/static/js/tracker/tracker-install-ac.js
@@ -0,0 +1,53 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+/**
+ * Sets up the legacy autocomplete editing widget on DOM elements that are
+ * set to use it.
+ */
+function TKR_install_ac() {
+ _ac_install();
+
+ _ac_register(function(input, event) {
+ if (input.id.startsWith('hotlists')) return TKR_hotlistsStore;
+ if (input.id.startsWith('search')) return TKR_searchStore;
+ if (input.id.startsWith('query_') || input.id.startsWith('predicate_')) {
+ return TKR_projectQueryStore;
+ }
+ if (input.id.startsWith('cmd')) return TKR_quickEditStore;
+ if (input.id.startsWith('labelPrefix')) return TKR_labelPrefixStore;
+ if (input.id.startsWith('label') && input.id != 'labelsInput') return TKR_labelStore;
+ if (input.dataset.acType === 'label' && input.id != 'labelsInput') return TKR_labelMultiStore;
+ if ((input.id.startsWith('component') || input.dataset.acType === 'component')
+ && input.id != 'componentsInput') return TKR_componentListStore;
+ if (input.id.startsWith('status')) return TKR_statusStore;
+ if (input.id.startsWith('member') || input.dataset.acType === 'member') return TKR_memberListStore;
+
+ if (input.id == 'admin_names_editor') return TKR_memberListStore;
+ if (input.id.startsWith('owner') && input.id != 'ownerInput') return TKR_ownerStore;
+ if (input.name == 'needs_perm' || input.name == 'grants_perm') {
+ return TKR_customPermissionsStore;
+ }
+ if (input.id == 'owner_editor' || input.dataset.acType === 'owner') return TKR_ownerStore;
+ if (input.className.indexOf('userautocomplete') != -1) {
+ const customFieldIDStr = input.name;
+ const uac = TKR_userAutocompleteStores[customFieldIDStr];
+ if (uac) return uac;
+ return TKR_ownerStore;
+ }
+ if (input.className.indexOf('autocomplete') != -1) {
+ return TKR_autoCompleteStore;
+ }
+ if (input.id.startsWith('copy_to') || input.id.startsWith('move_to') ||
+ input.id.startsWith('new_savedquery_projects') ||
+ input.id.startsWith('savedquery_projects')) {
+ return TKR_projectStore;
+ }
+ });
+};
diff --git a/static/js/tracker/tracker-keystrokes.js b/static/js/tracker/tracker-keystrokes.js
new file mode 100644
index 0000000..9a75971
--- /dev/null
+++ b/static/js/tracker/tracker-keystrokes.js
@@ -0,0 +1,232 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that implement keystroke accelerators
+ * for Monorail.
+ */
+
+/**
+ * Array of HTML elements where the kibbles cursor can be. E.g.,
+ * the TR elements of an issue list, or the TR's for comments on an issue.
+ */
+let TKR_cursorStops;
+
+/**
+ * Integer index into TKR_cursorStops of the currently selected cursor
+ * stop, or undefined if nothing has been selected yet.
+ */
+let TKR_selected = undefined;
+
+/**
+ * Register keystrokes that apply to all pages in the current component.
+ * E.g., keystrokes that should work on every page under the "Issues" tab.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ * @param {string} currentPageType One of 'list', 'entry', or 'detail'.
+ */
+function TKR_setupKibblesComponentKeys(listUrl, entryUrl, currentPageType) {
+ if (currentPageType != 'list') {
+ kibbles.keys.addKeyPressListener(
+ 'u', function() {
+ TKR_go(listUrl);
+ });
+ }
+}
+
+
+/**
+ * On the artifact list page, go to the artifact at the kibbles cursor.
+ * @param {number} linkCellIndex row child that is expected to hold a link.
+ */
+function TKR_openArtifactAtCursor(linkCellIndex, newWindow) {
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+ window._goIssue(TKR_selected, newWindow);
+ }
+}
+
+
+/**
+ * On the artifact list page, toggle the checkbox for the artifact at
+ * the kibbles cursor.
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox.
+ */
+function TKR_selectArtifactAtCursor(cbCellIndex) {
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+ const cell = TKR_cursorStops[TKR_selected].children[cbCellIndex];
+ let cb = cell.firstChild;
+ while (cb && cb.tagName != 'INPUT') {
+ cb = cb.nextSibling;
+ }
+ if (cb) {
+ cb.checked = cb.checked ? '' : 'checked';
+ TKR_highlightRow(cb);
+ }
+ }
+}
+
+/**
+ * On the artifact list page, toggle the star for the artifact at
+ * the kibbles cursor.
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox
+ * and star widget.
+ */
+function TKR_toggleStarArtifactAtCursor(cbCellIndex) {
+ if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+ const cell = TKR_cursorStops[TKR_selected].children[cbCellIndex];
+ let starIcon = cell.firstChild;
+ while (starIcon && starIcon.tagName != 'A') {
+ starIcon = starIcon.nextSibling;
+ }
+ if (starIcon) {
+ _TKR_toggleStar(
+ starIcon, issueRefs[TKR_selected]['project_name'],
+ issueRefs[TKR_selected]['id'], null, null);
+ }
+ }
+}
+
+/**
+ * Updates the style on new stop and clears the style on the former stop.
+ * @param {Object} newStop the cursor stop that the user is selecting now.
+ * @param {Object} formerStop the old cursor stop, if any.
+ */
+function TKR_updateCursor(newStop, formerStop) {
+ TKR_selected = undefined;
+ if (formerStop) {
+ formerStop.element.classList.remove('cursor_on');
+ formerStop.element.classList.add('cursor_off');
+ }
+ if (newStop && newStop.element) {
+ newStop.element.classList.remove('cursor_off');
+ newStop.element.classList.add('cursor_on');
+ TKR_selected = newStop.index;
+ }
+}
+
+
+/**
+ * Walk part of the page DOM to find elements that should be kibbles
+ * cursor stops. E.g., the rows of the issue list results table.
+ * @return {Array} an array of html elements.
+ */
+function TKR_findCursorRows() {
+ const rows = [];
+ const cursorarea = document.getElementById('cursorarea');
+ TKR_accumulateCursorRows(cursorarea, rows);
+ return rows;
+}
+
+
+/**
+ * Recusrively walk part of the page DOM to find elements that should
+ * be kibbles cursor stops. E.g., the rows of the issue list results
+ * table. The cursor stops are appended to the given rows array.
+ * @param {Element} parent html element to start on.
+ * @param {Array} rows array of html TR or DIV elements, each cursor stop will
+ * be added to this array.
+ */
+function TKR_accumulateCursorRows(parent, rows) {
+ for (let i = 0; i < parent.childNodes.length; i++) {
+ const elem = parent.childNodes[i];
+ const name = elem.tagName;
+ if (name && (name == 'TR' || name == 'DIV')) {
+ if (elem.className.indexOf('cursor') >= 0) {
+ elem.cursorIndex = rows.length;
+ rows.push(elem);
+ }
+ }
+ TKR_accumulateCursorRows(elem, rows);
+ }
+}
+
+
+/**
+ * Initialize kibbles cursors stops for the current page.
+ * @param {boolean} selectFirstStop True if the first stop should be
+ * selected before the user presses any keys.
+ */
+function TKR_setupKibblesCursorStops(selectFirstStop) {
+ kibbles.skipper.addStopListener(
+ kibbles.skipper.LISTENER_TYPE.PRE, TKR_updateCursor);
+
+ // Set the 'offset' option to return the middle of the client area
+ // an option can be a static value, or a callback
+ kibbles.skipper.setOption('padding_top', 50);
+
+ // Set the 'offset' option to return the middle of the client area
+ // an option can be a static value, or a callback
+ kibbles.skipper.setOption('padding_bottom', 50);
+
+ // register our stops with skipper
+ TKR_cursorStops = TKR_findCursorRows();
+ for (let i = 0; i < TKR_cursorStops.length; i++) {
+ const element = TKR_cursorStops[i];
+ kibbles.skipper.append(element);
+
+ if (element.className.indexOf('cursor_on') >= 0) {
+ kibbles.skipper.setCurrentStop(i);
+ }
+ }
+}
+
+
+/**
+ * Initialize kibbles keystrokes for an artifact entry page.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ */
+function TKR_setupKibblesOnEntryPage(listUrl, entryUrl) {
+ TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'entry');
+}
+
+
+/**
+ * Initialize kibbles keystrokes for an artifact list page.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ * @param {string} projectName Name of the current project.
+ * @param {number} linkCellIndex table column that is expected to
+ * link to individual artifacts.
+ * @param {number} opt_checkboxCellIndex table column that is expected
+ * to contain a selection checkbox.
+ */
+function TKR_setupKibblesOnListPage(
+ listUrl, entryUrl, projectName, linkCellIndex,
+ opt_checkboxCellIndex) {
+ TKR_setupKibblesCursorStops(true);
+
+ kibbles.skipper.addFwdKey('j');
+ kibbles.skipper.addRevKey('k');
+
+ if (opt_checkboxCellIndex != undefined) {
+ const cbCellIndex = opt_checkboxCellIndex;
+ kibbles.keys.addKeyPressListener(
+ 'x', function() {
+ TKR_selectArtifactAtCursor(cbCellIndex);
+ });
+ kibbles.keys.addKeyPressListener(
+ 's',
+ function() {
+ TKR_toggleStarArtifactAtCursor(cbCellIndex);
+ });
+ }
+ kibbles.keys.addKeyPressListener(
+ 'o', function() {
+ TKR_openArtifactAtCursor(linkCellIndex, false);
+ });
+ kibbles.keys.addKeyPressListener(
+ 'O', function() {
+ TKR_openArtifactAtCursor(linkCellIndex, true);
+ });
+ kibbles.keys.addKeyPressListener(
+ 'enter', function() {
+ TKR_openArtifactAtCursor(linkCellIndex);
+ });
+
+ TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'list');
+}
diff --git a/static/js/tracker/tracker-nav.js b/static/js/tracker/tracker-nav.js
new file mode 100644
index 0000000..4458a51
--- /dev/null
+++ b/static/js/tracker/tracker-nav.js
@@ -0,0 +1,182 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+
+/**
+ * This file contains JS functions that implement various navigation
+ * features of Monorail.
+ */
+
+
+/**
+ * Navigate the browser to the given URL.
+ * @param {string} url The URL of the page to browse.
+ * @param {boolean} newWindow Open a new tab or window.
+ */
+function TKR_go(url, newWindow) {
+ if (newWindow) {
+ window.open(url, '_blank');
+ } else {
+ document.location = url;
+ }
+}
+
+
+/**
+ * Tell the browser to scroll to the given anchor on the current page.
+ * @param {string} anchor Name of the <a name="xxx"> anchor on the page.
+ */
+function TKR_goToAnchor(anchor) {
+ document.location.hash = anchor;
+}
+
+
+/**
+ * Get the user-editable colspec form field. This text field is normally
+ * display:none, but it is shown when the user chooses "Edit columns...".
+ * We need a function to get this element because there are multiple form
+ * fields on the page with name="colspec", and an IE misfeature sets their
+ * id attributes as well, which makes document.getElementById() fail.
+ * @return {Element} user editable colspec form field.
+ */
+function TKR_getColspecElement() {
+ const elem = document.getElementById('colspec_field');
+ return elem && elem.firstChild;
+}
+
+
+/**
+ * Get the artifact search form field. This is a visible text field where
+ * the user enters a query for issues. This function
+ * is needed because there is also the project search field on the each page,
+ * and it has name="q". An IE misfeature confuses name="..." with id="...".
+ * @return {Element} artifact query form field, or undefined.
+ */
+function TKR_getArtifactSearchField() {
+ const element = _getSearchBarComponent();
+ if (!element) return $('searchq');
+
+ return element.shadowRoot.querySelector('#searchq');
+}
+
+
+/**
+ * Get the can selector. This function
+ * @return {Element} can input element.
+ */
+function TKR_getArtifactCanField() {
+ const element = _getSearchBarComponent();
+ if (!element) return $('can');
+
+ return element.shadowRoot.querySelector('#can');
+}
+
+
+function _getSearchBarComponent() {
+ const element = document.querySelector('mr-header');
+ if (!element) return;
+
+ return element.shadowRoot.querySelector('mr-search-bar');
+}
+
+
+/**
+ * Build a query string for all the common contextual values that we use.
+ */
+function TKR_formatContextQueryArgs() {
+ let args = '';
+ let colspec = _ctxDefaultColspec;
+ const colSpecElem = TKR_getColspecElement();
+ if (colSpecElem) {
+ colspec = colSpecElem.value;
+ }
+
+ if (_ctxHotlistID != '') args += '&hotlist_id=' + _ctxHotlistID;
+ if (_ctxCan != 2) args += '&can=' + _ctxCan;
+ args += '&q=' + encodeURIComponent(_ctxQuery);
+ if (_ctxSortspec != '') args += '&sort=' + _ctxSortspec;
+ if (_ctxGroupBy != '') args += '&groupby=' + _ctxGroupBy;
+ if (colspec != _ctxDefaultColspec) args += '&colspec=' + colspec;
+ if (_ctxStart != 0) args += '&start=' + _ctxStart;
+ if (_ctxNum != _ctxResultsPerPage) args += '&num=' + _ctxNum;
+ if (!colSpecElem) args += '&mode=grid';
+ return args;
+}
+
+// Fields that should use ":" when filtering.
+const _PRETOKENIZED_FIELDS = [
+ 'owner', 'reporter', 'cc', 'commentby', 'component'];
+
+/**
+ * The user wants to narrow their search results by adding a search term
+ * for the given prefix and value. Reload the issue list page with that
+ * additional search term.
+ * @param {string} prefix Field or label prefix, e.g., "Priority".
+ * @param {string} suffix Field or label value, e.g., "High".
+ */
+function TKR_filterTo(prefix, suffix) {
+ let newQuery = TKR_getArtifactSearchField().value;
+ if (newQuery != '') newQuery += ' ';
+
+ let op = '=';
+ for (let i = 0; i < _PRETOKENIZED_FIELDS.length; i++) {
+ if (prefix == _PRETOKENIZED_FIELDS[i]) {
+ op = ':';
+ break;
+ }
+ }
+
+ newQuery += prefix + op + suffix;
+ let url = 'list?can=' + TKR_getArtifactCanField().value + '&q=' + newQuery;
+ if ($('sort') && $('sort').value) url += '&sort=' + $('sort').value;
+ url += '&colspec=' + TKR_getColspecElement().value;
+ TKR_go(url);
+}
+
+
+/**
+ * The user wants to sort their search results by adding a sort spec
+ * for the given column. Reload the issue list page with that
+ * additional sort spec.
+ * @param {string} colname Field or label prefix, e.g., "Priority".
+ * @param {boolean} descending True if the values should be reversed.
+ */
+function TKR_addSort(colname, descending) {
+ let existingSortSpec = '';
+ if ($('sort')) {
+ existingSortSpec = $('sort').value;
+ }
+ const oldSpecs = existingSortSpec.split(/ +/);
+ let sortDirective = colname;
+ if (descending) sortDirective = '-' + colname;
+ const specs = [sortDirective];
+ for (let i = 0; i < oldSpecs.length; i++) {
+ if (oldSpecs[i] != '' && oldSpecs[i] != colname &&
+ oldSpecs[i] != '-' + colname) {
+ specs.push(oldSpecs[i]);
+ }
+ }
+
+ const isHotlist = window.location.href.includes('/hotlists/');
+ let url = isHotlist ? ($('hotlist_name').value + '?') : ('list?');
+ url += ('can='+ TKR_getArtifactCanField().value + '&q=' +
+ TKR_getArtifactSearchField().value);
+ url += '&sort=' + specs.join('+');
+ url += '&colspec=' + TKR_getColspecElement().value;
+ TKR_go(url);
+}
+
+/** Convenience function for sorting in ascending order. */
+function TKR_sortUp(colname) {
+ TKR_addSort(colname, false);
+}
+
+/** Convenience function for sorting in descending order. */
+function TKR_sortDown(colname) {
+ TKR_addSort(colname, true);
+}
+
diff --git a/static/js/tracker/tracker-onload.js b/static/js/tracker/tracker-onload.js
new file mode 100644
index 0000000..051c86d
--- /dev/null
+++ b/static/js/tracker/tracker-onload.js
@@ -0,0 +1,136 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+
+/**
+ * This file contains the Monorail onload() function that is called
+ * when each EZT page loads.
+ */
+
+
+/**
+ * This code is run on every DIT page load. It registers a handler
+ * for autocomplete on four different types of text fields based on the
+ * name of that text field.
+ */
+function TKR_onload() {
+ TKR_install_ac();
+ _PC_Install();
+ TKR_allColumnNames = _allColumnNames;
+ TKR_labelFieldIDPrefix = _lfidprefix;
+ TKR_allOrigLabels = _allOrigLabels;
+ TKR_initialFormValues = TKR_currentFormValues();
+}
+
+// External names for functions that are called directly from HTML.
+// JSCompiler does not rename functions that begin with an underscore.
+// They are not defined with "var" because we want them to be global.
+
+// TODO(jrobbins): the underscore names could be shortened by a
+// cross-file search-and-replace script in our build process.
+
+_selectAllIssues = TKR_selectAllIssues;
+_selectNoneIssues = TKR_selectNoneIssues;
+
+_toggleRows = TKR_toggleRows;
+_toggleColumn = TKR_toggleColumn;
+_toggleColumnUpdate = TKR_toggleColumnUpdate;
+_addGroupBy = TKR_addGroupBy;
+_addcol = TKR_addColumn;
+_checkRangeSelect = TKR_checkRangeSelect;
+_makeIssueLink = TKR_makeIssueLink;
+
+_onload = TKR_onload;
+
+_handleListActions = TKR_handleListActions;
+_handleDetailActions = TKR_handleDetailActions;
+
+_loadStatusSelect = TKR_loadStatusSelect;
+_fetchUserProjects = TKR_fetchUserProjects;
+_setACOptions = TKR_setUpAutoCompleteStore;
+_openIssueUpdateForm = TKR_openIssueUpdateForm;
+_addAttachmentFields = TKR_addAttachmentFields;
+_ignoreWidgetIfOpIsClear = TKR_ignoreWidgetIfOpIsClear;
+
+_acstore = _AC_SimpleStore;
+_accomp = _AC_Completion;
+_acreg = _ac_register;
+
+_formatContextQueryArgs = TKR_formatContextQueryArgs;
+_ctxArgs = '';
+_ctxCan = undefined;
+_ctxQuery = undefined;
+_ctxSortspec = undefined;
+_ctxGroupBy = undefined;
+_ctxDefaultColspec = undefined;
+_ctxStart = undefined;
+_ctxNum = undefined;
+_ctxResultsPerPage = undefined;
+
+_filterTo = TKR_filterTo;
+_sortUp = TKR_sortUp;
+_sortDown = TKR_sortDown;
+
+_closeAllPopups = TKR_closeAllPopups;
+_closeSubmenus = TKR_closeSubmenus;
+_showRight = TKR_showRight;
+_showBelow = TKR_showBelow;
+_highlightRow = TKR_highlightRow;
+
+_setFieldIDs = TKR_setFieldIDs;
+_selectTemplate = TKR_selectTemplate;
+_saveTemplate = TKR_saveTemplate;
+_newTemplate = TKR_newTemplate;
+_deleteTemplate = TKR_deleteTemplate;
+_switchTemplate = TKR_switchTemplate;
+_templateNames = TKR_templateNames;
+
+_confirmNovelStatus = TKR_confirmNovelStatus;
+_confirmNovelLabel = TKR_confirmNovelLabel;
+_vallab = TKR_validateLabel;
+_exposeExistingLabelFields = TKR_exposeExistingLabelFields;
+_confirmDiscardEntry = TKR_confirmDiscardEntry;
+_confirmDiscardUpdate = TKR_confirmDiscardUpdate;
+_lfidprefix = undefined;
+_allOrigLabels = undefined;
+_checkPlusOne = TKR_checkPlusOne;
+_checkUnrestrict = TKR_checkUnrestrict;
+
+_clearOnFirstEvent = TKR_clearOnFirstEvent;
+_forceProperTableWidth = TKR_forceProperTableWidth;
+
+_initialFormValues = TKR_initialFormValues;
+_currentFormValues = TKR_currentFormValues;
+
+_acof = _ac_onfocus;
+_acmo = _ac_mouseover;
+_acse = _ac_select;
+_acrob = _ac_ob;
+
+// Variables that are given values in the HTML file.
+_allColumnNames = [];
+
+_go = TKR_go;
+_getColspec = TKR_getColspecElement;
+
+// Make the document actually listen for click events, otherwise the
+// event handlers above would never get called.
+if (document.captureEvents) document.captureEvents(Event.CLICK);
+
+_setupKibblesOnEntryPage = TKR_setupKibblesOnEntryPage;
+_setupKibblesOnListPage = TKR_setupKibblesOnListPage;
+
+_checkFieldNameOnServer = TKR_checkFieldNameOnServer;
+_checkLeafName = TKR_checkLeafName;
+
+_addMultiFieldValueWidget = TKR_addMultiFieldValueWidget;
+_removeMultiFieldValueWidget = TKR_removeMultiFieldValueWidget;
+_trimCommas = TKR_trimCommas;
+
+_initDragAndDrop = TKR_initDragAndDrop;
diff --git a/static/js/tracker/tracker-update-issues-hotlists.js b/static/js/tracker/tracker-update-issues-hotlists.js
new file mode 100644
index 0000000..04a85bf
--- /dev/null
+++ b/static/js/tracker/tracker-update-issues-hotlists.js
@@ -0,0 +1,320 @@
+/* Copyright 2018 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that support a dialog for adding and removing
+ * issues from hotlists in Monorail.
+ */
+
+(function() {
+ window.__hotlists_dialog = window.__hotlists_dialog || {};
+
+ // An optional IssueRef.
+ // If set, we will not check for selected issues, and only add/remove issueRef
+ // instead.
+ window.__hotlists_dialog.issueRef = null;
+ // A function to be called with the modified hotlists. If issueRef is set, the
+ // hotlists for which the user is owner and the issue is part of will be
+ // passed as well.
+ window.__hotlists_dialog.onResponse = () => {};
+ // A function to be called if there was an error updating the hotlists.
+ window.__hotlists_dialog.onFailure = () => {};
+
+ /**
+ * A function to show the hotlist dialog.
+ * It is the only function exported by this module.
+ */
+ function ShowUpdateHotlistDialog() {
+ _FetchHotlists().then(_BuildDialog);
+ }
+
+ async function _CreateNewHotlistWithIssues() {
+ let selectedIssueRefs;
+ if (window.__hotlists_dialog.issueRef) {
+ selectedIssueRefs = [window.__hotlists_dialog.issueRef];
+ } else {
+ selectedIssueRefs = _GetSelectedIssueRefs();
+ }
+
+ const name = await _CheckNewHotlistName();
+ if (!name) {
+ return;
+ }
+
+ const message = {
+ name: name,
+ summary: 'Hotlist of bulk added issues',
+ issueRefs: selectedIssueRefs,
+ };
+ try {
+ await window.prpcClient.call(
+ 'monorail.Features', 'CreateHotlist', message);
+ } catch (error) {
+ window.__hotlists_dialog.onFailure(error);
+ return;
+ }
+
+ const newHotlist = [name, window.CS_env.loggedInUserEmail];
+ const newIssueHotlists = [];
+ window.__hotlists_dialog._issueHotlists.forEach(
+ hotlist => newIssueHotlists.push(hotlist.split('_')));
+ newIssueHotlists.push(newHotlist);
+ window.__hotlists_dialog.onResponse([newHotlist], newIssueHotlists);
+ }
+
+ async function _UpdateIssuesInHotlists() {
+ const hotlistRefsAdd = _GetSelectedHotlists(
+ window.__hotlists_dialog._userHotlists);
+ const hotlistRefsRemove = _GetSelectedHotlists(
+ window.__hotlists_dialog._issueHotlists);
+ if (hotlistRefsAdd.length === 0 && hotlistRefsRemove.length === 0) {
+ alert('Please select/un-select some hotlists');
+ return;
+ }
+
+ let selectedIssueRefs;
+ if (window.__hotlists_dialog.issueRef) {
+ selectedIssueRefs = [window.__hotlists_dialog.issueRef];
+ } else {
+ selectedIssueRefs = _GetSelectedIssueRefs();
+ }
+
+ if (hotlistRefsAdd.length > 0) {
+ const message = {
+ hotlistRefs: hotlistRefsAdd,
+ issueRefs: selectedIssueRefs,
+ };
+ try {
+ await window.prpcClient.call(
+ 'monorail.Features', 'AddIssuesToHotlists', message);
+ } catch (error) {
+ window.__hotlists_dialog.onFailure(error);
+ return;
+ }
+ hotlistRefsAdd.forEach(hotlist => {
+ window.__hotlists_dialog._issueHotlists.add(
+ hotlist.name + '_' + hotlist.owner.user_id);
+ });
+ }
+
+ if (hotlistRefsRemove.length > 0) {
+ const message = {
+ hotlistRefs: hotlistRefsRemove,
+ issueRefs: selectedIssueRefs,
+ };
+ try {
+ await window.prpcClient.call(
+ 'monorail.Features', 'RemoveIssuesFromHotlists', message);
+ } catch (error) {
+ window.__hotlists_dialog.onFailure(error);
+ return;
+ }
+ hotlistRefsRemove.forEach(hotlist => {
+ window.__hotlists_dialog._issueHotlists.delete(
+ hotlist.name + '_' + hotlist.owner.user_id);
+ });
+ }
+
+ const modifiedHotlists = hotlistRefsAdd.concat(hotlistRefsRemove).map(
+ hotlist => [hotlist.name, hotlist.owner.user_id]);
+ const newIssueHotlists = [];
+ window.__hotlists_dialog._issueHotlists.forEach(
+ hotlist => newIssueHotlists.push(hotlist.split('_')));
+
+ window.__hotlists_dialog.onResponse(modifiedHotlists, newIssueHotlists);
+ }
+
+ async function _FetchHotlists() {
+ const userHotlistsMessage = {
+ user: {
+ display_name: window.CS_env.loggedInUserEmail,
+ }
+ };
+ const userHotlistsResponse = await window.prpcClient.call(
+ 'monorail.Features', 'ListHotlistsByUser', userHotlistsMessage);
+
+ // Here we have the list of all hotlists owned by the user. We filter out
+ // the hotlists that already contain issueRef in the next paragraph of code.
+ window.__hotlists_dialog._userHotlists = new Set();
+ (userHotlistsResponse.hotlists || []).forEach(hotlist => {
+ window.__hotlists_dialog._userHotlists.add(
+ hotlist.name + '_' + hotlist.ownerRef.userId);
+ });
+
+ // Here we filter out the hotlists that are owned by the user, and that
+ // contain issueRef from _userHotlists and save them into _issueHotlists.
+ window.__hotlists_dialog._issueHotlists = new Set();
+ if (window.__hotlists_dialog.issueRef) {
+ const issueHotlistsMessage = {
+ issue: window.__hotlists_dialog.issueRef,
+ };
+ const issueHotlistsResponse = await window.prpcClient.call(
+ 'monorail.Features', 'ListHotlistsByIssue', issueHotlistsMessage);
+ (issueHotlistsResponse.hotlists || []).forEach(hotlist => {
+ const hotlistRef = hotlist.name + '_' + hotlist.ownerRef.userId;
+ if (window.__hotlists_dialog._userHotlists.has(hotlistRef)) {
+ window.__hotlists_dialog._userHotlists.delete(hotlistRef);
+ window.__hotlists_dialog._issueHotlists.add(hotlistRef);
+ }
+ });
+ }
+ }
+
+ function _BuildDialog() {
+ const table = $('js-hotlists-table');
+
+ while (table.firstChild) {
+ table.removeChild(table.firstChild);
+ }
+
+ if (window.__hotlists_dialog._issueHotlists.size > 0) {
+ _UpdateRows(
+ table, 'Remove issues from:',
+ window.__hotlists_dialog._issueHotlists);
+ }
+ _UpdateRows(table, 'Add issues to:',
+ window.__hotlists_dialog._userHotlists);
+ _BuildCreateNewHotlist(table);
+
+ $('update-issues-hotlists').style.display = 'block';
+ $('save-issues-hotlists').addEventListener(
+ 'click', _UpdateIssuesInHotlists);
+ $('cancel-update-hotlists').addEventListener('click', function() {
+ $('update-issues-hotlists').style.display = 'none';
+ });
+
+ }
+
+ function _BuildCreateNewHotlist(table) {
+ const inputTr = document.createElement('tr');
+ inputTr.classList.add('hotlist_rows');
+
+ const inputCell = document.createElement('td');
+ const input = document.createElement('input');
+ input.setAttribute('id', 'text_new_hotlist_name');
+ input.setAttribute('placeholder', 'New hotlist name');
+ // Hotlist changes are automatic and should be ignored by
+ // TKR_currentFormValues() and TKR_isDirty()
+ input.setAttribute('ignore-dirty', true);
+ input.addEventListener('input', _CheckNewHotlistName);
+ inputCell.appendChild(input);
+ inputTr.appendChild(inputCell);
+
+ const buttonCell = document.createElement('td');
+ const button = document.createElement('button');
+ button.setAttribute('id', 'create-new-hotlist');
+ button.addEventListener('click', _CreateNewHotlistWithIssues);
+ button.textContent = 'Create New Hotlist';
+ button.disabled = true;
+ buttonCell.appendChild(button);
+ inputTr.appendChild(buttonCell);
+
+ table.appendChild(inputTr);
+
+ const feedbackTr = document.createElement('tr');
+ feedbackTr.classList.add('hotlist_rows');
+
+ const feedbackCell = document.createElement('td');
+ feedbackCell.setAttribute('colspan', '2');
+ const feedback = document.createElement('span');
+ feedback.classList.add('fielderror');
+ feedback.setAttribute('id', 'hotlistnamefeedback');
+ feedbackCell.appendChild(feedback);
+ feedbackTr.appendChild(feedbackCell);
+
+ table.appendChild(feedbackTr);
+ }
+
+ function _UpdateRows(table, title, hotlists) {
+ const tr = document.createElement('tr');
+ tr.classList.add('hotlist_rows');
+ const addCell = document.createElement('td');
+ const add = document.createElement('b');
+ add.textContent = title;
+ addCell.appendChild(add);
+ tr.appendChild(addCell);
+ table.appendChild(tr);
+
+ hotlists.forEach(hotlist => {
+ const hotlistParts = hotlist.split('_');
+ const name = hotlistParts[0];
+
+ const tr = document.createElement('tr');
+ tr.classList.add('hotlist_rows');
+
+ const cbCell = document.createElement('td');
+ const cb = document.createElement('input');
+ cb.classList.add('checkRangeSelect');
+ cb.setAttribute('id', 'cb_hotlist_' + hotlist);
+ cb.setAttribute('type', 'checkbox');
+ // Hotlist changes are automatic and should be ignored by
+ // TKR_currentFormValues() and TKR_isDirty()
+ cb.setAttribute('ignore-dirty', true);
+ cbCell.appendChild(cb);
+
+ const nameCell = document.createElement('td');
+ const label = document.createElement('label');
+ label.htmlFor = cb.id;
+ label.textContent = name;
+ nameCell.appendChild(label);
+
+ tr.appendChild(cbCell);
+ tr.appendChild(nameCell);
+ table.appendChild(tr);
+ });
+ }
+
+ async function _CheckNewHotlistName() {
+ const name = $('text_new_hotlist_name').value;
+ const checkNameResponse = await window.prpcClient.call(
+ 'monorail.Features', 'CheckHotlistName', {name});
+
+ if (checkNameResponse.error) {
+ $('hotlistnamefeedback').textContent = checkNameResponse.error;
+ $('create-new-hotlist').disabled = true;
+ return null;
+ }
+
+ $('hotlistnamefeedback').textContent = '';
+ $('create-new-hotlist').disabled = false;
+ return name;
+ }
+
+ /**
+ * Call GetSelectedIssuesRefs from tracker-editing.js and convert to an Array
+ * of IssueRef PBs.
+ */
+ function _GetSelectedIssueRefs() {
+ return GetSelectedIssuesRefs().map(issueRef => ({
+ project_name: issueRef['project_name'],
+ local_id: issueRef['id'],
+ }));
+ }
+
+ /**
+ * Get HotlistRef PBs for the hotlists that the user wants to add/remove the
+ * selected issues to.
+ */
+ function _GetSelectedHotlists(hotlists) {
+ const selectedHotlistRefs = [];
+ hotlists.forEach(hotlist => {
+ const checkbox = $('cb_hotlist_' + hotlist);
+ const hotlistParts = hotlist.split('_');
+ if (checkbox && checkbox.checked) {
+ selectedHotlistRefs.push({
+ name: hotlistParts[0],
+ owner: {
+ user_id: hotlistParts[1],
+ }
+ });
+ }
+ });
+ return selectedHotlistRefs;
+ }
+
+ Object.assign(window.__hotlists_dialog, {ShowUpdateHotlistDialog});
+})();
diff --git a/static/js/tracker/tracker-util.js b/static/js/tracker/tracker-util.js
new file mode 100644
index 0000000..040f8c1
--- /dev/null
+++ b/static/js/tracker/tracker-util.js
@@ -0,0 +1,166 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS utilities used by other JS files in Monorail.
+ */
+
+
+/**
+ * Add an indexOf method to all arrays, if this brower's JS implementation
+ * does not already have it.
+ * @param {Object} item The item to find
+ * @return {number} The index of the given item, or -1 if not found.
+ */
+if (Array.prototype.indexOf == undefined) {
+ Array.prototype.indexOf = function(item) {
+ for (let i = 0; i < this.length; ++i) {
+ if (this[i] == item) return i;
+ }
+ return -1;
+ };
+}
+
+
+/**
+ * This function works around a FF HTML layout problem. The table
+ * width is somehow rendered at 100% when the table contains a
+ * display:none element, later, when that element is displayed, the
+ * table renders at the correct width. The work-around is to have the
+ * element initiallye displayed so that the table renders properly,
+ * but then immediately hide the element until it is needed.
+ *
+ * TODO(jrobbins): Find HTML markup that FF can render more
+ * consistently. After that, I can remove this hack.
+ */
+function TKR_forceProperTableWidth() {
+ let e = $('confirmarea');
+ if (e) e.style.display='none';
+}
+
+
+function TKR_parseIssueRef(issueRef) {
+ issueRef = issueRef.trim();
+ if (!issueRef) {
+ return null;
+ }
+
+ let projectName = window.CS_env.projectName;
+ let localId = issueRef;
+ if (issueRef.includes(':')) {
+ const parts = issueRef.split(':', 2);
+ projectName = parts[0];
+ localId = parts[1];
+ }
+
+ return {
+ project_name: projectName,
+ local_id: localId};
+}
+
+
+function _buildFieldsForIssueDelta(issueDelta, valuesByName) {
+ issueDelta.field_vals_add = [];
+ issueDelta.field_vals_remove = [];
+ issueDelta.fields_clear = [];
+
+ valuesByName.forEach((values, key, map) => {
+ if (key.startsWith('op_custom_') && values == 'clear') {
+ const field_id = key.substring('op_custom_'.length);
+ issueDelta.fields_clear.push({field_id: field_id});
+ } else if (key.startsWith('custom_')) {
+ const field_id = key.substring('custom_'.length);
+ values = values.filter(Boolean);
+ if (valuesByName.get('op_' + key) === 'remove') {
+ values.forEach((value) => {
+ issueDelta.field_vals_remove.push({
+ field_ref: {field_id: field_id},
+ value: value});
+ });
+ } else {
+ values.forEach((value) => {
+ issueDelta.field_vals_add.push({
+ field_ref: {field_id: field_id},
+ value: value});
+ });
+ }
+ }
+ });
+}
+
+
+function _classifyPlusMinusItems(values) {
+ let result = {
+ add: [],
+ remove: []};
+ values = new Set(values);
+ values.forEach((value) => {
+ if (!value.startsWith('-') && value) {
+ result.add.push(value);
+ } else if (value.startsWith('-') && value.substring(1)) {
+ result.remove.push(value);
+ }
+ });
+ return result;
+}
+
+
+function TKR_buildIssueDelta(valuesByName) {
+ let issueDelta = {};
+
+ if (valuesByName.has('status')) {
+ issueDelta.status = valuesByName.get('status')[0];
+ }
+ if (valuesByName.has('owner')) {
+ issueDelta.owner_ref = {
+ display_name: valuesByName.get('owner')[0].trim().toLowerCase()};
+ }
+ if (valuesByName.has('cc')) {
+ const cc_usernames = _classifyPlusMinusItems(
+ valuesByName.get('cc')[0].toLowerCase().split(/[,;\s]+/));
+ issueDelta.cc_refs_add = cc_usernames.add.map(
+ (email) => ({display_name: email}));
+ issueDelta.cc_refs_remove = cc_usernames.remove.map(
+ (email) => ({display_name: email}));
+ }
+ if (valuesByName.has('components')) {
+ const components = _classifyPlusMinusItems(
+ valuesByName.get('components')[0].split(/[,;\s]/));
+ issueDelta.comp_refs_add = components.add.map(
+ (path) => ({path: path}));
+ issueDelta.comp_refs_remove = components.remove.map(
+ (path) => ({path: path}));
+ }
+ if (valuesByName.has('label')) {
+ const labels = _classifyPlusMinusItems(valuesByName.get('label'));
+ issueDelta.label_refs_add = labels.add.map(
+ (label) => ({label: label}));
+ issueDelta.label_refs_remove = labels.remove.map(
+ (label) => ({label: label}));
+ }
+ if (valuesByName.has('blocked_on')) {
+ const blockedOn = _classifyPlusMinusItems(valuesByName.get('blocked_on'));
+ issueDelta.blocked_on_refs_add = blockedOn.add.map(TKR_parseIssueRef);
+ issueDelta.blocked_on_refs_add = blockedOn.remove.map(TKR_parseIssueRef);
+ }
+ if (valuesByName.has('blocking')) {
+ const blocking = _classifyPlusMinusItems(valuesByName.get('blocking'));
+ issueDelta.blocking_refs_add = blocking.add.map(TKR_parseIssueRef);
+ issueDelta.blocking_refs_add = blocking.remove.map(TKR_parseIssueRef);
+ }
+ if (valuesByName.has('merge_into')) {
+ issueDelta.merged_into_ref = TKR_parseIssueRef(
+ valuesByName.get('merge_into')[0]);
+ }
+ if (valuesByName.has('summary')) {
+ issueDelta.summary = valuesByName.get('summary')[0];
+ }
+
+ _buildFieldsForIssueDelta(issueDelta, valuesByName);
+
+ return issueDelta;
+}
diff --git a/static/js/tracker/trackerac_test.js b/static/js/tracker/trackerac_test.js
new file mode 100644
index 0000000..583fb01
--- /dev/null
+++ b/static/js/tracker/trackerac_test.js
@@ -0,0 +1,132 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+const feedData = {
+ 'open': [{name: 'New', doc: 'Newly reported'},
+ {name: 'Started', doc: 'Work has begun'}],
+ 'closed': [{name: 'Fixed', doc: 'Problem was fixed'},
+ {name: 'Invalid', doc: 'Bad issue report'}],
+ 'labels': [{name: 'Type-Defect', doc: 'Something is broken'},
+ {name: 'Type-Enhancement', doc: 'It could be better'},
+ {name: 'Priority-High', doc: 'Urgent'},
+ {name: 'Priority-Low', doc: 'Not so urgent'},
+ {name: 'Hot', doc: ''},
+ {name: 'Cold', doc: ''}],
+ 'members': [{name: 'jrobbins', doc: ''},
+ {name: 'jrobbins@chromium.org', doc: ''}],
+ 'excl_prefixes': [],
+ 'strict': false,
+};
+
+function setUp() {
+ TKR_autoCompleteFeedName = 'issueOptions';
+}
+
+/**
+ * The assertEquals method cannot do element-by-element comparisons.
+ * A search of how other teams write JS unit tests turned up this
+ * way to compare arrays.
+ */
+function assertElementsEqual(arrayA, arrayB) {
+ assertEquals(arrayA.join(' ;; '), arrayB.join(' ;; '));
+}
+
+function completionsEqual(strings, completions) {
+ if (strings.length != completions.length) {
+ return false;
+ }
+ for (let i = 0; i < strings.length; i++) {
+ if (strings[i] != completions[i].value) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function assertHasCompletion(s, acStore) {
+ const ch = s.charAt(0).toLowerCase();
+ const firstCharMapArray = acStore.firstCharMap_[ch];
+ assertNotNull(!firstCharMapArray);
+ for (let i = 0; i < firstCharMapArray.length; i++) {
+ if (s == firstCharMapArray[i].value) return;
+ }
+ fail('completion ' + s + ' not found in acStore[' +
+ acStoreToString(acStore) + ']');
+}
+
+function assertHasAllCompletions(stringArray, acStore) {
+ for (let i = 0; i < stringArray.length; i++) {
+ assertHasCompletion(stringArray[i], acStore);
+ }
+}
+
+function acStoreToString(acStore) {
+ const allCompletions = [];
+ for (const ch in acStore.firstCharMap_) {
+ if (acStore.firstCharMap_.hasOwnProperty(ch)) {
+ const firstCharArray = acStore.firstCharMap_[ch];
+ for (let i = 0; i < firstCharArray.length; i++) {
+ allCompletions[firstCharArray[i].value] = true;
+ }
+ }
+ }
+ const parts = [];
+ for (const comp in allCompletions) {
+ if (allCompletions.hasOwnProperty(comp)) {
+ parts.push(comp);
+ }
+ }
+ return parts.join(', ');
+}
+
+function testSetUpStatusStore() {
+ TKR_setUpStatusStore(feedData.open, feedData.closed);
+ assertElementsEqual(
+ ['New', 'Started', 'Fixed', 'Invalid'],
+ TKR_statusWords);
+ assertHasAllCompletions(
+ ['New', 'Started', 'Fixed', 'Invalid'],
+ TKR_statusStore);
+}
+
+function testSetUpSearchStore() {
+ TKR_setUpSearchStore(
+ feedData.labels, feedData.members, feedData.open, feedData.closed);
+ assertHasAllCompletions(
+ ['status:New', 'status:Started', 'status:Fixed', 'status:Invalid',
+ '-status:New', '-status:Started', '-status:Fixed', '-status:Invalid',
+ 'Type=Defect', '-Type=Defect', 'Type=Enhancement', '-Type=Enhancement',
+ 'label:Hot', 'label:Cold', '-label:Hot', '-label:Cold',
+ 'owner:jrobbins', 'cc:jrobbins', '-owner:jrobbins', '-cc:jrobbins',
+ 'summary:', 'opened-after:today-1', 'commentby:me', 'reporter:me'],
+ TKR_searchStore);
+}
+
+function testSetUpQuickEditStore() {
+ TKR_setUpQuickEditStore(
+ feedData.labels, feedData.members, feedData.open, feedData.closed);
+ assertHasAllCompletions(
+ ['status=New', 'status=Started', 'status=Fixed', 'status=Invalid',
+ 'Type=Defect', 'Type=Enhancement', 'Hot', 'Cold', '-Hot', '-Cold',
+ 'owner=jrobbins', 'owner=me', 'cc=jrobbins', 'cc=me', 'cc=-jrobbins',
+ 'cc=-me', 'summary=""', 'owner=----'],
+ TKR_quickEditStore);
+}
+
+function testSetUpLabelStore() {
+ TKR_setUpLabelStore(feedData.labels);
+ assertHasAllCompletions(
+ ['Type-Defect', 'Type-Enhancement', 'Hot', 'Cold'],
+ TKR_labelStore);
+}
+
+function testSetUpMembersStore() {
+ TKR_setUpMemberStore(feedData.members);
+ assertHasAllCompletions(
+ ['jrobbins', 'jrobbins@chromium.org'],
+ TKR_memberListStore);
+}
diff --git a/static/js/tracker/trackerediting_test.js b/static/js/tracker/trackerediting_test.js
new file mode 100644
index 0000000..27d45bf
--- /dev/null
+++ b/static/js/tracker/trackerediting_test.js
@@ -0,0 +1,69 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+
+function testKeepJustSummaryPrefixes_NoPrefixes() {
+ assertEquals(
+ '',
+ TKR_keepJustSummaryPrefixes(''));
+
+ assertEquals(
+ '',
+ TKR_keepJustSummaryPrefixes('Enter one line summary'));
+
+ assertEquals(
+ '',
+ TKR_keepJustSummaryPrefixes('Translation problem [en]'));
+
+ assertEquals(
+ '',
+ TKR_keepJustSummaryPrefixes('Crash at HH:MM'));
+}
+
+function testKeepJustSummaryPrefixes_WithColons() {
+ assertEquals(
+ 'Security: ',
+ TKR_keepJustSummaryPrefixes('Security:'));
+
+ assertEquals(
+ 'Exploit: ',
+ TKR_keepJustSummaryPrefixes('Exploit: remote exploit'));
+
+ assertEquals(
+ 'XSS:Security: ',
+ TKR_keepJustSummaryPrefixes('XSS:Security: rest of summary'));
+
+ assertEquals(
+ 'XSS: Security: ',
+ TKR_keepJustSummaryPrefixes('XSS: Security: rest of summary'));
+
+ assertEquals(
+ 'XSS-Security: ',
+ TKR_keepJustSummaryPrefixes('XSS-Security: rest of summary'));
+
+ assertEquals(
+ 'XSS: Security: ',
+ TKR_keepJustSummaryPrefixes('XSS: Security: rest [of] su:mmary'));
+
+ assertEquals(
+ 'XSS-Security: ',
+ TKR_keepJustSummaryPrefixes('XSS-Security: rest [of] su:mmary'));
+}
+
+function testKeepJustSummaryPrefixes_WithBrackets() {
+ assertEquals(
+ '[Printing] ',
+ TKR_keepJustSummaryPrefixes('[Printing] problem with page'));
+
+ assertEquals(
+ '[Printing] ',
+ TKR_keepJustSummaryPrefixes('[Printing] problem with page'));
+
+ assertEquals(
+ '[l10n][en] ',
+ TKR_keepJustSummaryPrefixes('[l10n][en]Translation problem'));
+}
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..c7ede5b
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,17 @@
+User-agent: *
+# Start by disallowing everything.
+Disallow: /
+# Some specific things are okay, though.
+Allow: /$
+Allow: /hosting
+Allow: /p/*/adminIntro
+# Allow files needed to render the new UI
+Allow: /prpc/*
+Allow: /static/*
+# Query strings are hard. We only allow ?id=N, no other parameters.
+Allow: /p/*/issues/detail?id=*
+Allow: /p/*/issues/detail_ezt?id=*
+Disallow: /p/*/issues/detail?id=*&*
+Disallow: /p/*/issues/detail?*&id=*
+# 10 second crawl delay for bots that honor it.
+Crawl-delay: 10
diff --git a/static/third_party/js/keys.js b/static/third_party/js/keys.js
new file mode 100644
index 0000000..1f2a7ff
--- /dev/null
+++ b/static/third_party/js/keys.js
@@ -0,0 +1,192 @@
+/**
+ * Copyright 2008 Steve McKay.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Kibbles.Keys is a Javascript library providing simple cross browser
+ * keyboard event support.
+ */
+(function(){
+
+var _listening = false;
+
+// code to handler list map.
+// Wildcard listeners use magic code wildcards "before" and "after".
+var _listeners = {
+ before: [],
+ after: []
+};
+
+/*
+ * Map of key names to char code. This map is consulted before
+ * charCodeAt(0) is used to determine the character code.
+ *
+ * This map also serves as a definitive list of supported "special" keys.
+ * See _codeForEvent for details.
+ */
+var _CODE_MAP = {
+ ESC: 27,
+ ENTER: 13
+};
+
+/**
+ * Register a keypress listener.
+ */
+function _listen() {
+ if (_listening) return;
+
+ var d = document;
+ if (d.addEventListener) {
+ d.addEventListener('keypress', _handleKeyboardEvent, false);
+ d.addEventListener('keydown', _handleKeyDownEvent, false);
+ } else if (d.attachEvent) {
+ d.documentElement.attachEvent('onkeypress', _handleKeyboardEvent);
+ d.documentElement.attachEvent('onkeydown', _handleKeyDownEvent);
+ }
+ _listening = true;
+}
+
+/**
+ * Register a keypress listener for the supplied skip code.
+ */
+function _addKeyPressListener(spec, handler) {
+ var code = spec.toLowerCase();
+ if (code == "before" || code == "after") {
+ _listeners[code].push(handler);
+ return;
+ }
+
+ // try to find the character or key code.
+ code = _CODE_MAP[spec.toUpperCase()];
+ if (!code) {
+ code = spec.charCodeAt(0);
+ }
+ if (!_listeners[code]) {
+ _listeners[code] = [];
+ }
+ _listeners[code].push(handler);
+}
+
+/**
+ * Our handler for keypress events.
+ */
+function _handleKeyboardEvent(e) {
+
+ // If event is null, this is probably IE.
+ if (!e) e = window.event;
+
+ var source = _getSourceElement(e);
+ if (_isInputElement(source)) {
+ return;
+ }
+
+ if (_hasFlakeyModifier(e)) return;
+
+ var code = _codeForEvent(e);
+
+ if (code == undefined) return;
+
+ var payload = {
+ code: code
+ };
+
+ for (var i = 0; i < _listeners.before.length; i++) {
+ _listeners.before[i](payload);
+ }
+
+ var listeners = _listeners[code];
+ if (listeners) {
+ for (var i = 0; i < listeners.length; i++) {
+ listeners[i]({
+ code: code
+ });
+ }
+ }
+
+ for (var i = 0; i < _listeners.after.length; i++) {
+ _listeners.after[i](payload);
+ }
+}
+
+function _handleKeyDownEvent(e) {
+ if (!e) e = window.event;
+ var code = _codeForEvent(e);
+ if (code == _CODE_MAP['ESC'] || code == _CODE_MAP['ENTER']) {
+ _handleKeyboardEvent(e);
+ }
+}
+
+/**
+ * Returns the keycode associated with the event.
+ */
+function _codeForEvent(e) {
+ return e.keyCode ? e.keyCode : e.which;
+}
+
+/**
+ * Returns true if the supplied event has an associated modifier key
+ * that we have had trouble with in certain browsers.
+ */
+function _hasFlakeyModifier(e) {
+ return e.altKey || e.ctrlKey || e.metaKey;
+}
+
+/**
+ * Returns the source element for the supplied event.
+ */
+function _getSourceElement(e) {
+ var element = e.target;
+ if (!element) {
+ element = e.srcElement;
+ }
+
+ if (element.shadowRoot) {
+ // Find the element within the shadowDOM.
+ const path = e.path || e.composedPath();
+ element = path[0];
+ }
+
+ // If the source element is a text node, the parent is the object
+ // we're interested in.
+ if (element.nodeType == 3) {
+ element = element.parentNode;
+ }
+
+ return element;
+}
+
+/**
+ * Returns true if the element is a known form input element.
+ */
+function _isInputElement(element) {
+ return element.tagName == 'INPUT' || element.tagName == 'TEXTAREA';
+}
+
+/*
+ * A nice little namespace to call our own.
+ *
+ * Formalizing Kibbles.Keys as a traditional javascript class caused headaches
+ * with respect to capturing the context (what is "this" at any point in time).
+ * So we use a simple script exported via the "kibbles.keys" namespace.
+ */
+if (!window.kibbles)
+ window.kibbles = {}
+
+window.kibbles.keys = {
+ listen: _listen,
+ addKeyPressListener: _addKeyPressListener
+};
+
+})();
diff --git a/static/third_party/js/skipper.js b/static/third_party/js/skipper.js
new file mode 100644
index 0000000..4c131b1
--- /dev/null
+++ b/static/third_party/js/skipper.js
@@ -0,0 +1,335 @@
+/**
+ * Copyright 2008 Steve McKay.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Kibbles.Skipper is a Javascript library providing support for keyboard
+ * navigation among DOM object on a page.
+ */
+(function(){
+
+var _stops = new Array(); // list of stop objects
+var _lastStop; // id of the last stop we visited to.
+
+// Named options. The value can be a literal value, or a function to call.
+var _options = {
+ padding_top: 0, // window offset when scrolling
+ padding_bottom: 0,
+ scroll_window: true
+};
+
+/*
+ * Constants identifying listener types. Used with the method that
+ * enables registration of listeners.
+ */
+var _LISTENER_TYPE = {
+ PRE: 'pre',
+ POST: 'post'
+};
+
+// map of stop listeners by type. pre listeners are called before navigation
+// post listeners are called after navigation.
+var _stopListener = {
+ pre: [],
+ post: []
+};
+
+/**
+ * Remove all stop previously identified stop elements.
+ */
+function _reset() {
+ _stops = new Array();
+}
+
+function _get(i) {
+ return _stops[i];
+}
+
+function _set(i, element) {
+ _stops[i] = element;
+}
+
+function _insert(i, element) {
+ if (i < 0 || i > _stops.length - 1) {
+ throw "Index out of bounds.";
+ }
+ _stops.splice(i, 0, element);
+ if (i <= _lastStop) {
+ _lastStop++;
+ }
+}
+
+function _append(element) {
+ _stops.push(element);
+}
+
+function _del(i) {
+ if (i < 0 || i > _stops.length - 1) {
+ throw "Index out of bounds.";
+ }
+ _stops.splice(i, 1);
+ if (_lastStop >= i) {
+ _lastStop--;
+ }
+}
+
+function _length() {
+ return _stops.length;
+}
+
+/**
+ * Sets the named option to the specified value.
+ */
+function _setOption(name, value) {
+ _options[name] = value;
+}
+
+/**
+ * Register a key to move forward one stop.
+ */
+function _addFwdKey(character) {
+ kibbles.keys.addKeyPressListener(character, _gotoNextStop);
+}
+
+/**
+ * Register a key to move back one stop.
+ */
+function _addRevKey(character) {
+ kibbles.keys.addKeyPressListener(character, _gotoPreviousStop);
+}
+
+/**
+ * Adds a stop listener.
+ */
+function _addStopListener(type, handler) {
+ if (type == _LISTENER_TYPE.PRE) {
+ _stopListener.pre.push(handler);
+ } else if (type == _LISTENER_TYPE.POST) {
+ _stopListener.post.push(handler);
+ }
+}
+
+/**
+ * Scroll to next stop if any.
+ */
+function _gotoNextStop() {
+ _setCurrentStop(_getNextStop());
+}
+
+/**
+ * Scroll to previous stop if any.
+ */
+function _gotoPreviousStop() {
+ _setCurrentStop(_getPreviousStop());
+}
+
+/**
+ * Update the current and previous stops, scrolling window to the location
+ * of the specified stop, and notifying listeners in the process.
+ */
+function _setCurrentStop(i) {
+ if (i >= 0) {
+ var prevStop = _lastStop;
+ _lastStop = i;
+
+ var next = new Stop(i);
+ var prev = (prevStop >= 0) ? new Stop(prevStop) : undefined;
+
+ _notifyListeners(next, prev, _stopListener.pre);
+
+ // If the y coord of the stop was not previously determined
+ // it may have been hidden. Since "PRE" listeners may reveal
+ // hidden stops, we try again if "y" is not know.
+ if (!next.y) next.y = _findObjectPosition(next.element);
+
+ // if we can't id the y coords at this point, we throw an exception.
+ if (!next.y && !(next.y >= 0)) {
+ throw "Next stop does not y coords. Aborting.";
+ }
+ _notifyListeners(next, prev, _stopListener.post);
+ }
+}
+
+/**
+ * Called by a listener, not directly.
+ */
+function _scrollOpportunityListener(next, prev) {
+ if (!_getOptionValue('scroll_window')) return;
+
+ if (next && next.element) {
+
+ var viewTop = _windowScrollTop();
+ var viewBottom = viewTop + document.documentElement.clientHeight;
+
+ var padTop = _getOptionValue('padding_top');
+
+ var bottom = viewBottom - padTop;
+
+ // if we skipped below the bottom padding
+ if (next.y > bottom) {
+ window.scrollTo(0, next.y - padTop);
+ return;
+ }
+
+ var padBottom = _getOptionValue('padding_bottom');
+ // if we skipped above the top offset
+ var top = viewTop + padBottom;
+ if (next.y < top) {
+ window.scrollTo(0, (next.y - document.documentElement.clientHeight) + padBottom);
+ return;
+ }
+ }
+}
+
+function _windowScrollTop() {
+ if (window.document.body.scrollTop) {
+ return window.document.body.scrollTop;
+ } else if (window.document.documentElement.scrollTop) {
+ return window.document.documentElement.scrollTop;
+ } else if (window.pageYOffset) {
+ return window.pageYOffset;
+ }
+ return 0;
+}
+
+
+/**
+ * Returns an option value or if the value is a function,
+ * the value returned by the function.
+ */
+function _getOptionValue(name) {
+ var opt = _options[name];
+ if (typeof opt == "function") {
+ return opt();
+ }
+ return opt;
+}
+
+/**
+ * Notify all supplied stop listeners.
+ */
+function _notifyListeners(stop, previousStop, listeners) {
+ if (stop && listeners) {
+ try {
+ for (var i = 0; i < listeners.length; i++) {
+ listeners[i](stop, previousStop);
+ }
+ } catch(err) {
+ // don't let a grumpy listener bring us down.
+ }
+ }
+}
+
+/**
+ * Returns the next stop or null if none stop available.
+ */
+function _getNextStop() {
+ var i = 0;
+
+ // if we've already visited a stop, use that as the base for the next stop.
+ if (_lastStop >= 0) {
+ i = _lastStop + 1;
+ }
+
+ // if the presumed next stop is out of bounds, return null.
+ if (i > _stops.length - 1) {
+ return;
+ }
+ return i;
+}
+
+/**
+ * Returns the previous stop or null if none available.
+ */
+function _getPreviousStop() {
+ var i = _stops.length - 1;
+
+ // if we've already visited a stop, use that as the base for the next stop.
+ if (_lastStop >= 0) {
+ i = _lastStop - 1;
+ }
+
+ // if the presumed next stop is out of bounds, return null.
+ if (i < 0) {
+ return;
+ }
+ return i;
+}
+
+/**
+ * Convenience wrapper for "stop" related information.
+ */
+function Stop(i, y) {
+ this.index = i;
+ this.element = _stops[i];
+ this.y = _findObjectPosition(this.element);
+}
+
+/**
+ * Returns the vertical coordinate of the top of specified object
+ * relative to the top of the entire page.
+ */
+function _findObjectPosition(obj) {
+ if (obj) {
+ var curtop = 0;
+ if (obj.offsetParent) {
+ while (obj.offsetParent) {
+ curtop += obj.offsetTop;
+ obj = obj.offsetParent;
+ }
+ } else if (obj.y) {
+ curtop += obj.y;
+ }
+ return curtop;
+ }
+ return null;
+}
+
+if (!window.kibbles.keys) {
+ throw "Kibbles.Skipper requires Kibbles.Keys which is not loaded."
+ + " Can't continue.";
+}
+
+/**
+ * A nice little namespace to call our own.
+ *
+ * Formalizing Kibbles.Skipper as a traditional javascript class caused
+ * headaches with respect to capturing the context (what is "this"
+ * at any point in time). So we use a simple script exported via the
+ * "kibbles.skipper" namespace.
+ */
+window.kibbles.skipper = {
+ setOption: _setOption,
+ addFwdKey: _addFwdKey,
+ addRevKey: _addRevKey,
+ LISTENER_TYPE: _LISTENER_TYPE,
+ addStopListener: _addStopListener,
+ setCurrentStop: _setCurrentStop,
+ // array like methods for stop manipulation
+ get: _get,
+ set: _set,
+ append: _append,
+ insert: _insert,
+ del: _del,
+ length: _length,
+ reset: _reset
+}
+
+_addStopListener(kibbles.skipper.LISTENER_TYPE.POST, _scrollOpportunityListener)
+
+// we depend on kibbles.keys.
+kibbles.keys.listen();
+
+})();