Surface errors in hashtag flow

Apply & Create buttons are also replaced with a single Cancel button.

https://imgur.com/a/peeCuau

Release-Notes: skip
Change-Id: I9d08e31121101f8df73c2944e3208a7a3e6796d5
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
index 97b64d8..cd4834c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -12,6 +12,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-icon/iron-icon';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {getAppContext} from '../../../services/app-context';
 import {notUndefined} from '../../../types/types';
@@ -103,6 +104,7 @@
         .loadingOrError {
           display: flex;
           gap: var(--spacing-s);
+          align-items: baseline;
         }
 
         /* The basics of .loadingSpin are defined in spinnerStyles. */
@@ -111,6 +113,17 @@
           position: relative;
           top: 3px;
         }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        iron-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
       `,
     ];
   }
@@ -171,21 +184,35 @@
                   ${this.renderLoadingOrError()}
                 </div>
                 <div class="buttons">
-                  <gr-button
-                    id="create-new-hashtag-button"
-                    flatten
-                    @click=${() =>
-                      this.applyHashtags('Creating hashtag...', true)}
-                    .disabled=${isCreateNewHashtagDisabled}
-                    >Create new hashtag</gr-button
-                  >
-                  <gr-button
-                    id="apply-hashtag-button"
-                    flatten
-                    @click=${() => this.applyHashtags('Applying hashtag...')}
-                    .disabled=${this.isApplyHashtagDisabled()}
-                    >Apply</gr-button
-                  >
+                  ${when(
+                    this.overallProgress !== ProgressStatus.FAILED,
+                    () => html`
+                      <gr-button
+                        id="create-new-hashtag-button"
+                        flatten
+                        @click=${() =>
+                          this.applyHashtags('Creating hashtag...', true)}
+                        .disabled=${isCreateNewHashtagDisabled}
+                        >Create new hashtag</gr-button
+                      >
+                      <gr-button
+                        id="apply-hashtag-button"
+                        flatten
+                        @click=${() =>
+                          this.applyHashtags('Applying hashtag...')}
+                        .disabled=${this.isApplyHashtagDisabled()}
+                        >Apply</gr-button
+                      >
+                    `,
+                    () => html`
+                      <gr-button
+                        id="cancel-button"
+                        flatten
+                        @click=${this.closeDropdown}
+                        >Cancel</gr-button
+                      >
+                    `
+                  )}
                 </div>
               </div>
             </div>
@@ -225,15 +252,20 @@
   }
 
   private renderLoadingOrError() {
-    if (this.overallProgress === ProgressStatus.RUNNING) {
-      return html`
-        <span class="loadingSpin"></span>
-        <span class="loadingText">${this.loadingText}</span>
-      `;
-    } else if (this.errorText !== undefined) {
-      return html`<div class="error">${this.errorText}</div>`;
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <iron-icon icon="gr-icons:error"></iron-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
     }
-    return nothing;
   }
 
   private isApplyHashtagDisabled() {
@@ -256,12 +288,7 @@
   }
 
   private toggleDropdown() {
-    if (this.isDropdownOpen) {
-      this.closeDropdown();
-    } else {
-      this.reset();
-      this.openDropdown();
-    }
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
   }
 
   private reset() {
@@ -277,6 +304,7 @@
   }
 
   private openDropdown() {
+    this.reset();
     this.isDropdownOpen = true;
     this.dropdown?.open();
   }
@@ -316,11 +344,16 @@
     this.loadingText = loadingText;
     this.trackPromises(
       this.getBulkActionsModel().addHashtags(allHashtagsToApply),
-      alert
+      alert,
+      creatingHashtag ? 'Failed to create' : 'Failed to apply'
     );
   }
 
-  private async trackPromises(promises: Promise<Hashtag[]>[], alert: string) {
+  private async trackPromises(
+    promises: Promise<Hashtag[]>[],
+    alert: string,
+    errorText: string
+  ) {
     this.overallProgress = ProgressStatus.RUNNING;
     const results = await allSettled(promises);
     if (results.every(result => result.status === 'fulfilled')) {
@@ -332,7 +365,7 @@
       this.dropdown?.notifyResize();
     } else {
       this.overallProgress = ProgressStatus.FAILED;
-      // TODO: when some are rejected, show error and Cancel button
+      this.errorText = errorText;
     }
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
index 176382d..b805046 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -16,6 +16,7 @@
 import {
   MockPromise,
   mockPromise,
+  query,
   queryAll,
   queryAndAssert,
   stubRestApi,
@@ -173,6 +174,13 @@
       await element.updateComplete;
     }
 
+    async function rejectPromises() {
+      setChangeHashtagPromises[0].reject(new Error('error'));
+      setChangeHashtagPromises[1].reject(new Error('error'));
+      setChangeHashtagPromises[2].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
     setup(async () => {
       stubRestApi('getDetailedChangesWithActions').resolves(changes);
       setChangeHashtagPromises = [];
@@ -329,6 +337,34 @@
       );
     });
 
+    test('shows error when apply hashtag fails', async () => {
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to apply'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
     test('apply multiple hashtag from selected change', async () => {
       const alertStub = sinon.stub();
       element.addEventListener('show-alert', alertStub);
@@ -482,11 +518,50 @@
         queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
       );
       assert.equal(
-        queryAll<HTMLSpanElement>(element, 'button.chip')[2].innerText,
+        queryAll<HTMLButtonElement>(element, 'button.chip')[2].innerText,
         'foo'
       );
     });
 
+    test('shows error when create hashtag fails', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to create'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
     test('cannot apply existing hashtag already on selected changes', async () => {
       const alertStub = sinon.stub();
       element.addEventListener('show-alert', alertStub);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index 4477f4a..e286a74 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -327,12 +327,7 @@
   }
 
   private toggleDropdown() {
-    if (this.isDropdownOpen) {
-      this.closeDropdown();
-    } else {
-      this.reset();
-      this.openDropdown();
-    }
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
   }
 
   private reset() {
@@ -348,6 +343,7 @@
   }
 
   private openDropdown() {
+    this.reset();
     this.isDropdownOpen = true;
     this.dropdown?.open();
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 9d853de..c639701 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -388,6 +388,10 @@
         queryAndAssert(element, '.error').textContent,
         'Failed to remove topic'
       );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
       assert.isUndefined(query(element, '.loadingText'));
     });
 
@@ -645,6 +649,10 @@
         queryAndAssert(element, '.error').textContent,
         'Failed to create topic'
       );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
       assert.isUndefined(query(element, '.loadingText'));
     });
 
@@ -725,6 +733,10 @@
         queryAndAssert(element, '.error').textContent,
         'Failed to apply topic'
       );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
       assert.isUndefined(query(element, '.loadingText'));
     });
   });