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'));
+}