Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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'));
+}