Merge "Add the ability to display error to gr-autocomplete."
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 26c8a50..91e601c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -11,6 +11,7 @@
 import {FitController} from '../../lit/fit-controller';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
 import {repeat} from 'lit/directives/repeat.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ShortcutController} from '../../lit/shortcut-controller';
@@ -54,6 +55,12 @@
   @property({type: Boolean, reflect: true, attribute: 'is-hidden'})
   isHidden = true;
 
+  /** If specified a single non-interactable line is shown instead of
+   * suggestions.
+   */
+  @property({type: String})
+  errorMessage?: String;
+
   @property({type: Number})
   verticalOffset = 0;
 
@@ -110,6 +117,12 @@
         li.selected {
           background-color: var(--hover-background-color);
         }
+        li.query-error {
+          background-color: var(--disabled-background);
+          color: var(--error-foreground);
+          cursor: default;
+          white-space: pre-wrap;
+        }
         @media only screen and (max-height: 35em) {
           .dropdown-content {
             max-height: 80vh;
@@ -126,25 +139,25 @@
     ];
   }
 
+  private isSuggestionListInteractible() {
+    return !this.isHidden && !this.errorMessage;
+  }
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
     this.shortcuts.addLocal({key: Key.UP, allowRepeat: true}, () =>
-      this.handleUp()
+      this.cursorUp()
     );
     this.shortcuts.addLocal({key: Key.DOWN, allowRepeat: true}, () =>
-      this.handleDown()
+      this.cursorDown()
     );
     this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
     this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-  }
-
   override disconnectedCallback() {
     this.cursor.unsetCursor();
     super.disconnectedCallback();
@@ -167,27 +180,46 @@
     }
   }
 
+  private renderError() {
+    return html`
+      <li
+        tabindex="-1"
+        aria-label="autocomplete query error"
+        class="query-error"
+      >
+        <span>${this.errorMessage}</span>
+        <span class="label">ERROR</span>
+      </li>
+    `;
+  }
+
   override render() {
     return html`
       <div class="dropdown-content" id="suggestions" role="listbox">
         <ul>
-          ${repeat(
-            this.suggestions,
-            (item, index) => html`
-              <li
-                data-index=${index}
-                data-value=${item.dataValue ?? ''}
-                tabindex="-1"
-                aria-label=${item.name ?? ''}
-                class="autocompleteOption"
-                role="option"
-                @click=${this.handleClickItem}
-              >
-                <span>${item.text}</span>
-                <span class="label ${this.computeLabelClass(item)}"
-                  >${item.label}</span
-                >
-              </li>
+          ${when(
+            this.errorMessage,
+            () => this.renderError(),
+            () => html`
+              ${repeat(
+                this.suggestions,
+                (item, index) => html`
+                  <li
+                    data-index=${index}
+                    data-value=${item.dataValue ?? ''}
+                    tabindex="-1"
+                    aria-label=${item.name ?? ''}
+                    class="autocompleteOption"
+                    role="option"
+                    @click=${this.handleClickItem}
+                  >
+                    <span>${item.text}</span>
+                    <span class="label ${this.computeLabelClass(item)}"
+                      >${item.label}</span
+                    >
+                  </li>
+                `
+              )}
             `
           )}
         </ul>
@@ -204,55 +236,54 @@
   }
 
   getCurrentText() {
-    return this.getCursorTarget()?.dataset['value'] || '';
+    if (!this.errorMessage) {
+      return this.getCursorTarget()?.dataset['value'] || '';
+    }
+    return '';
   }
 
   setPositionTarget(target: HTMLElement) {
     this.fitController.setPositionTarget(target);
   }
 
-  private handleUp() {
-    if (!this.isHidden) this.cursorUp();
-  }
-
-  private handleDown() {
-    if (!this.isHidden) this.cursorDown();
-  }
-
   cursorDown() {
-    if (!this.isHidden) this.cursor.next();
+    if (this.isSuggestionListInteractible()) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) this.cursor.previous();
+    if (this.isSuggestionListInteractible()) this.cursor.previous();
   }
 
   // private but used in tests
   handleTab() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'tab',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'tab',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
   }
 
   // private but used in tests
   handleEnter() {
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'enter',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'enter',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
   }
 
   private handleEscape() {
@@ -293,7 +324,7 @@
   computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
       this.cursor.stops = Array.from(
-        this.suggestionsDiv?.querySelectorAll('li') ?? []
+        this.suggestionsDiv?.querySelectorAll('li.autocompleteOption') ?? []
       );
       this.resetCursorIndex();
     } else {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index cb83396..54d054b 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -18,155 +18,233 @@
 import {Key} from '../../../utils/dom-util';
 
 suite('gr-autocomplete-dropdown', () => {
-  let element: GrAutocompleteDropdown;
+  suite('suggestion tests', () => {
+    let element: GrAutocompleteDropdown;
 
-  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+    const suggestionsEl = () => queryAndAssert(element, '#suggestions');
 
-  setup(async () => {
-    element = await fixture(
-      html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
-    );
-    element.open();
-    element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
-    ];
-    await waitEventLoop();
-  });
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.suggestions = [
+        {
+          dataValue: 'test value 1',
+          name: 'test name 1',
+          text: '1',
+          label: 'hi',
+        },
+        {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+      ];
+      await waitEventLoop();
+    });
 
-  teardown(() => {
-    element.close();
-  });
+    teardown(() => {
+      element.close();
+    });
 
-  test('renders', () => {
-    assert.shadowDom.equal(
-      element,
-      /* HTML */ `
-        <div class="dropdown-content" id="suggestions" role="listbox">
-          <ul>
-            <li
-              aria-label="test name 1"
-              class="autocompleteOption selected"
-              data-index="0"
-              data-value="test value 1"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 1 </span>
-              <span class="label"> hi </span>
-            </li>
-            <li
-              aria-label="test name 2"
-              class="autocompleteOption"
-              data-index="1"
-              data-value="test value 2"
-              role="option"
-              tabindex="-1"
-            >
-              <span> 2 </span>
-              <span class="hide label"> </span>
-            </li>
-          </ul>
-        </div>
-      `
-    );
-  });
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="test name 1"
+                class="autocompleteOption selected"
+                data-index="0"
+                data-value="test value 1"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 1 </span>
+                <span class="label"> hi </span>
+              </li>
+              <li
+                aria-label="test name 2"
+                class="autocompleteOption"
+                data-index="1"
+                data-value="test value 2"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 2 </span>
+                <span class="hide label"> </span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
 
-  test('shows labels', () => {
-    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
-    assert.equal(els[0].innerText.trim(), '1\nhi');
-    assert.equal(els[1].innerText.trim(), '2');
-  });
+    test('shows labels', () => {
+      const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(els[0].innerText.trim(), '1\nhi');
+      assert.equal(els[1].innerText.trim(), '2');
+    });
 
-  test('escape key', async () => {
-    const closeSpy = sinon.spy(element, 'close');
-    pressKey(element, Key.ESC);
-    await waitEventLoop();
-    assert.isTrue(closeSpy.called);
-  });
+    test('escape key close suggestions', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
 
-  test('tab key', () => {
-    const handleTabSpy = sinon.spy(element, 'handleTab');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.TAB);
-    assert.isTrue(handleTabSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.isTrue(itemSelectedStub.called);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'tab',
-      selected: element.getCursorTarget(),
+    test('tab key', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.isHidden = true;
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.isHidden = true;
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      element.cursor.setCursorAtIndex(1);
+      assert.equal(element.cursor.index, 1);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+    });
+
+    test('tapping selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      suggestionsEl().querySelectorAll('li')[1].click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: suggestionsEl().querySelectorAll('li')[1],
+      });
+    });
+
+    test('tapping child still selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
+        ?.lastElementChild;
+      assertIsDefined(lastElChild);
+      (lastElChild as HTMLSpanElement).click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', async () => {
+      const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
+      element.suggestions = [];
+      await waitUntil(() => resetStopsSpy.called);
     });
   });
 
-  test('enter key', () => {
-    const handleEnterSpy = sinon.spy(element, 'handleEnter');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    pressKey(element, Key.ENTER);
-    assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'enter',
-      selected: element.getCursorTarget(),
+  suite('error tests', () => {
+    let element: GrAutocompleteDropdown;
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.errorMessage = 'Failed query error';
+      await waitEventLoop();
     });
-  });
 
-  test('down key', () => {
-    element.isHidden = true;
-    const nextSpy = sinon.spy(element.cursor, 'next');
-    pressKey(element, 'ArrowDown');
-    assert.isFalse(nextSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    pressKey(element, 'ArrowDown');
-    assert.isTrue(nextSpy.called);
-    assert.equal(element.cursor.index, 1);
-  });
-
-  test('up key', () => {
-    element.isHidden = true;
-    const prevSpy = sinon.spy(element.cursor, 'previous');
-    pressKey(element, 'ArrowUp');
-    assert.isFalse(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    element.cursor.setCursorAtIndex(1);
-    assert.equal(element.cursor.index, 1);
-    pressKey(element, 'ArrowUp');
-    assert.isTrue(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-  });
-
-  test('tapping selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    suggestionsEl().querySelectorAll('li')[1].click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: suggestionsEl().querySelectorAll('li')[1],
+    teardown(() => {
+      element.close();
     });
-  });
 
-  test('tapping child still selects item', async () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
-      ?.lastElementChild;
-    assertIsDefined(lastElChild);
-    (lastElChild as HTMLSpanElement).click();
-    await waitEventLoop();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+    test('renders error', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query error"
+                class="query-error"
+                tabindex="-1"
+              >
+                <span>Failed query error</span>
+                <span class="label">ERROR</span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
     });
-  });
 
-  test('updated suggestions resets cursor stops', async () => {
-    const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
-    element.suggestions = [];
-    await waitUntil(() => resetStopsSpy.called);
+    test('escape key close dropdown with error', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('tab key when error shown sends no event', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('enter key when error shown sends no event', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('up/down disabled when error', () => {
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.isFalse(prevSpy.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 34e67b9..f7f8ea5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -81,6 +81,9 @@
    * next to the "name" as label text. The "value" property will be emitted
    * if that suggestion is selected.
    *
+   * If query fails, the function should return rejected promise containing
+   * an Error. The "message" property will be shown in a dropdown instead of
+   * rendering suggestions.
    */
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
@@ -169,6 +172,8 @@
 
   @state() suggestions: AutocompleteSuggestion[] = [];
 
+  @state() queryErrorMessage?: string;
+
   @state() index: number | null = null;
 
   // Enabled to suppress showing/updating suggestions when changing properties
@@ -269,7 +274,10 @@
     ) {
       this.updateSuggestions();
     }
-    if (changedProperties.has('suggestions')) {
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('queryErrorMessage')
+    ) {
       this.updateDropdownVisibility();
     }
     if (changedProperties.has('text')) {
@@ -317,6 +325,7 @@
         @item-selected=${this.handleItemSelect}
         @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
         .suggestions=${this.suggestions}
+        .errorMessage=${this.queryErrorMessage}
         role="listbox"
         .index=${this.index}
       >
@@ -424,6 +433,7 @@
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
     this.suggestions = [];
+    this.queryErrorMessage = undefined;
 
     // TODO(taoalpha): Also skip if text has not changed
 
@@ -446,19 +456,28 @@
     }
 
     const update = () => {
-      query(this.text).then(suggestions => {
-        if (this.text !== this.text) {
-          // Late response.
-          return;
-        }
-        for (const suggestion of suggestions) {
-          suggestion.text = suggestion?.name ?? '';
-        }
-        this.suggestions = suggestions;
-        if (this.index === -1) {
+      query(this.text)
+        .then(suggestions => {
+          if (this.text !== this.text) {
+            // Late response.
+            return;
+          }
+          for (const suggestion of suggestions) {
+            suggestion.text = suggestion?.name ?? '';
+          }
+          this.suggestions = suggestions;
+          if (this.index === -1) {
+            this.value = '';
+          }
+        })
+        .catch(e => {
           this.value = '';
-        }
-      });
+          if (typeof e === 'string') {
+            this.queryErrorMessage = e;
+          } else if (e instanceof Error) {
+            this.queryErrorMessage = e.message;
+          }
+        });
     };
 
     if (this.noDebounce) {
@@ -479,7 +498,10 @@
   }
 
   updateDropdownVisibility() {
-    if (this.suggestions.length > 0 && this.focused) {
+    if (
+      (this.suggestions.length > 0 || this.queryErrorMessage) &&
+      this.focused
+    ) {
       this.suggestionsDropdown?.open();
       return;
     }
@@ -526,6 +548,7 @@
         }
         if (this.suggestions.length > 0) {
           // If suggestions are shown, act as if the keypress is in dropdown.
+          // suggestions length is 0 if error is shown.
           this.handleItemSelectEnter(e);
         } else {
           e.preventDefault();
@@ -553,8 +576,9 @@
   }
 
   cancel() {
-    if (this.suggestions.length) {
+    if (this.suggestions.length || this.queryErrorMessage) {
       this.suggestions = [];
+      this.queryErrorMessage = undefined;
       this.requestUpdate();
     } else {
       fireEvent(this, 'cancel');
@@ -562,8 +586,11 @@
   }
 
   handleInputCommit(_tabComplete?: boolean) {
-    // Nothing to do if the dropdown is not open.
-    if (!this.allowNonSuggestedValues && this.suggestionsDropdown?.isHidden) {
+    // Nothing to do if no suggestions.
+    if (
+      !this.allowNonSuggestedValues &&
+      (this.suggestionsDropdown?.isHidden || this.queryErrorMessage)
+    ) {
       return;
     }
 
@@ -641,6 +668,7 @@
     }
 
     this.suggestions = [];
+    this.queryErrorMessage = undefined;
     // we need willUpdate to send text-changed event before we can send the
     // 'commit' event
     await this.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index d991180..e593c0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -6,7 +6,12 @@
 import '../../../test/common-test-setup';
 import './gr-autocomplete';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
-import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {
+  assertFails,
+  pressKey,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -109,6 +114,46 @@
     );
   });
 
+  test('renders with error', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.reject(new Error(`${input} not allowed`))
+    );
+    element.query = queryStub;
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+    assert.equal(element.suggestionsDropdown?.errorMessage, 'blah not allowed');
+  });
+
   test('cursor starts on suggestions', async () => {
     const queryStub = sinon.spy((input: string) =>
       Promise.resolve([
@@ -181,6 +226,39 @@
     });
   });
 
+  test('esc key behavior on error', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) => (promise = Promise.reject(new Error('Test error')))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+      assert.equal(element.queryErrorMessage, 'Test error');
+
+      pressKey(inputEl(), Key.ESC);
+      await waitUntil(() => suggestionsEl().isHidden);
+
+      assert.isFalse(cancelHandler.called);
+      assert.isUndefined(element.queryErrorMessage);
+
+      pressKey(inputEl(), Key.ESC);
+      await element.updateComplete;
+
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
   test('emits commit and handles cursor movement', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
@@ -403,6 +481,25 @@
     });
   });
 
+  test('error should not carry over', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns((promise = Promise.reject(new Error('Test error'))));
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    await element.updateComplete;
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => element.queryErrorMessage === 'Test error');
+      element.text = '';
+      element.threshold = 0;
+      element.noDebounce = false;
+      await element.updateComplete;
+      assert.isUndefined(element.queryErrorMessage);
+    });
+  });
+
   test('multi completes only the last part of the query', async () => {
     let promise;
     const queryStub = sinon
@@ -504,7 +601,7 @@
 
   test(
     'handleInputCommit with autocomplete hidden does nothing without' +
-      'without allowNonSuggestedValues',
+      ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       suggestionsEl().isHidden = true;
@@ -514,6 +611,17 @@
   );
 
   test(
+    'handleInputCommit with query error does nothing without' +
+      ' allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
     'handleInputCommit with autocomplete hidden with' +
       'allowNonSuggestedValues',
     () => {
@@ -525,6 +633,17 @@
     }
   );
 
+  test(
+    'handleInputCommit with query error with' + 'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
   test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
@@ -587,6 +706,21 @@
       await element.updateComplete;
 
       assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('enter in input does not re-render error', async () => {
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error message';
+
+      pressKey(inputEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
       assert.isTrue(suggestionsEl().isHidden);
     });