Merge "Pre-factor for gr-dropdown migration."
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 9e2132b..0e43823 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
@@ -29,7 +29,7 @@
 export class GrChangeListHashtagFlow extends LitElement {
   @state() private selectedChanges: ChangeInfo[] = [];
 
-  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+  @state() private hashtagToApply: Hashtag = '' as Hashtag;
 
   @state() private existingHashtagSuggestions: Hashtag[] = [];
 
@@ -81,6 +81,7 @@
           display: flex;
           flex-wrap: wrap;
           gap: 6px;
+          padding-bottom: var(--spacing-l);
         }
         .chip {
           padding: var(--spacing-s) var(--spacing-xl);
@@ -124,6 +125,16 @@
 
   override render() {
     const isFlowDisabled = this.selectedChanges.length === 0;
+    const isCreateNewHashtagDisabled =
+      this.hashtagToApply === '' ||
+      this.existingHashtagSuggestions.includes(this.hashtagToApply) ||
+      this.selectedExistingHashtags.size !== 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    const isApplyHashtagDisabled =
+      (this.selectedExistingHashtags.size === 0 &&
+        (this.hashtagToApply === '' ||
+          !this.existingHashtagSuggestions.includes(this.hashtagToApply))) ||
+      this.overallProgress === ProgressStatus.RUNNING;
     return html`
       <gr-button
         id="start-flow"
@@ -144,11 +155,38 @@
           this.isDropdownOpen,
           () => html`
             <div slot="dropdown-content">
-              ${when(
-                this.selectedChanges.some(change => change.hashtags?.length),
-                () => this.renderExistingHashtagsMode(),
-                () => this.renderNoExistingHashtagsMode()
-              )}
+              ${this.renderExistingHashtags()}
+              <!--
+                The .query function needs to be bound to this because lit's
+                autobind seems to work only for @event handlers.
+              -->
+              <gr-autocomplete
+                .text=${this.hashtagToApply}
+                .query=${(query: string) => this.getHashtagSuggestions(query)}
+                show-blue-focus-border
+                placeholder="Type hashtag name to create or filter hashtags"
+                @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+                  (this.hashtagToApply = e.detail.value)}
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+                <div class="buttons">
+                  <gr-button
+                    id="create-new-hashtag-button"
+                    flatten
+                    @click=${() => this.applyHashtags('Creating hashtag...')}
+                    .disabled=${isCreateNewHashtagDisabled}
+                    >Create new hashtag</gr-button
+                  >
+                  <gr-button
+                    id="apply-hashtag-button"
+                    flatten
+                    @click=${() => this.applyHashtags('Applying hashtag...')}
+                    .disabled=${isApplyHashtagDisabled}
+                    >Apply</gr-button
+                  >
+                </div>
+              </div>
             </div>
           `
         )}
@@ -156,38 +194,15 @@
     `;
   }
 
-  private renderExistingHashtagsMode() {
+  private renderExistingHashtags() {
     const hashtags = this.selectedChanges
       .flatMap(change => change.hashtags ?? [])
       .filter(notUndefined)
       .filter(unique);
-    const removeDisabled =
-      this.selectedExistingHashtags.size === 0 ||
-      this.overallProgress === ProgressStatus.RUNNING;
-    const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
     return html`
       <div class="chips">
         ${hashtags.map(name => this.renderExistingHashtagChip(name))}
       </div>
-      <div class="footer">
-        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
-        <div class="buttons">
-          <gr-button
-            id="apply-to-all-button"
-            flatten
-            ?disabled=${applyToAllDisabled}
-            @click=${this.applyHashtagToAll}
-            >Apply${this.selectedChanges.length > 1 ? ' to all' : nothing}
-          </gr-button>
-          <gr-button
-            id="remove-hashtags-button"
-            flatten
-            ?disabled=${removeDisabled}
-            @click=${this.removeHashtags}
-            >Remove</gr-button
-          >
-        </div>
-      </div>
     `;
   }
 
@@ -220,53 +235,6 @@
     return nothing;
   }
 
-  private renderNoExistingHashtagsMode() {
-    const isCreateNewHashtagDisabled =
-      this.hashtagToAdd === '' ||
-      this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
-      this.overallProgress === ProgressStatus.RUNNING;
-    const isApplyHashtagDisabled =
-      this.hashtagToAdd === '' ||
-      !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
-      this.overallProgress === ProgressStatus.RUNNING;
-    return html`
-      <!--
-        The .query function needs to be bound to this because lit's autobind
-        seems to work only for @event handlers.
-        'this.getHashtagSuggestions.bind(this)' gets in trouble with our linter
-        even though the bind is necessary here, so an anonymous function is used
-        instead.
-      -->
-      <gr-autocomplete
-        .text=${this.hashtagToAdd}
-        .query=${(query: string) => this.getHashtagSuggestions(query)}
-        show-blue-focus-border
-        placeholder="Type hashtag name to create or filter hashtags"
-        @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
-          (this.hashtagToAdd = e.detail.value)}
-      ></gr-autocomplete>
-      <div class="footer">
-        <div class="loadingOrError">${this.renderLoadingOrError()}</div>
-        <div class="buttons">
-          <gr-button
-            id="create-new-hashtag-button"
-            flatten
-            @click=${() => this.addHashtag('Creating hashtag...')}
-            .disabled=${isCreateNewHashtagDisabled}
-            >Create new hashtag</gr-button
-          >
-          <gr-button
-            id="apply-hashtag-button"
-            flatten
-            @click=${() => this.addHashtag('Applying hashtag...')}
-            .disabled=${isApplyHashtagDisabled}
-            >Apply</gr-button
-          >
-        </div>
-      </div>
-    `;
-  }
-
   private toggleDropdown() {
     if (this.isDropdownOpen) {
       this.closeDropdown();
@@ -277,7 +245,7 @@
   }
 
   private reset() {
-    this.hashtagToAdd = '' as Hashtag;
+    this.hashtagToApply = '' as Hashtag;
     this.selectedExistingHashtags = new Set();
     this.overallProgress = ProgressStatus.NOT_STARTED;
     this.errorText = undefined;
@@ -308,44 +276,16 @@
     });
   }
 
-  private removeHashtags() {
-    this.loadingText = `Removing hashtag${
-      this.selectedExistingHashtags.size > 1 ? 's' : ''
-    }...`;
-    this.trackPromises(
-      this.selectedChanges
-        .filter(
-          change =>
-            change.hashtags &&
-            change.hashtags.some(hashtag =>
-              this.selectedExistingHashtags.has(hashtag)
-            )
-        )
-        .map(change =>
-          this.restApiService.setChangeHashtag(change._number, {
-            remove: Array.from(this.selectedExistingHashtags.values()),
-          })
-        )
-    );
-  }
-
-  private applyHashtagToAll() {
-    this.loadingText = 'Applying hashtag to all';
-    this.trackPromises(
-      this.selectedChanges.map(change =>
-        this.restApiService.setChangeHashtag(change._number, {
-          add: Array.from(this.selectedExistingHashtags.values()),
-        })
-      )
-    );
-  }
-
-  private addHashtag(loadingText: string) {
+  private applyHashtags(loadingText: string) {
+    const allHashtagsToApply = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToApply === '' ? [] : [this.hashtagToApply]),
+    ];
     this.loadingText = loadingText;
     this.trackPromises(
       this.selectedChanges.map(change =>
         this.restApiService.setChangeHashtag(change._number, {
-          add: [this.hashtagToAdd],
+          add: allHashtagsToApply,
         })
       )
     );
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 77517e7..f29ad39 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
@@ -133,8 +133,8 @@
     });
   });
 
-  suite('changes in existing hashtags', () => {
-    const changesWithHashtags: ChangeInfo[] = [
+  suite('hashtag flow', () => {
+    const changes: ChangeInfo[] = [
       {
         ...createChange(),
         _number: 1 as NumericChangeId,
@@ -147,6 +147,11 @@
         subject: 'Subject 2',
         hashtags: ['hashtag2' as Hashtag],
       },
+      {
+        ...createChange(),
+        _number: 3 as NumericChangeId,
+        subject: 'Subject 3',
+      },
     ];
     let setChangeHashtagPromises: MockPromise<string>[];
     let setChangeHashtagStub: sinon.SinonStub;
@@ -158,20 +163,18 @@
     }
 
     setup(async () => {
-      stubRestApi('getDetailedChangesWithActions').resolves(
-        changesWithHashtags
-      );
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
       setChangeHashtagPromises = [];
       setChangeHashtagStub = stubRestApi('setChangeHashtag');
-      for (let i = 0; i < changesWithHashtags.length; i++) {
+      for (let i = 0; i < changes.length; i++) {
         const promise = mockPromise<string>();
         setChangeHashtagPromises.push(promise);
         setChangeHashtagStub
-          .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+          .withArgs(changes[i]._number, sinon.match.any)
           .returns(promise);
       }
       model = new BulkActionsModel(getAppContext().restApiService);
-      model.sync(changesWithHashtags);
+      model.sync(changes);
 
       element = (
         await fixture(
@@ -184,9 +187,10 @@
       ).querySelector('gr-change-list-hashtag-flow')!;
 
       // select changes
-      await selectChange(changesWithHashtags[0]);
-      await selectChange(changesWithHashtags[1]);
-      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await selectChange(changes[2]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 3);
       await element.updateComplete;
 
       // open flow
@@ -195,7 +199,7 @@
       await flush();
     });
 
-    test('renders existing-hashtags flow', () => {
+    test('renders hashtags flow', () => {
       expect(element).shadowDom.to.equal(
         /* HTML */ `
           <gr-button
@@ -221,214 +225,6 @@
                   >hashtag2</span
                 >
               </div>
-              <div class="footer">
-                <div class="loadingOrError"></div>
-                <div class="buttons">
-                  <gr-button
-                    id="apply-to-all-button"
-                    flatten=""
-                    aria-disabled="true"
-                    disabled=""
-                    role="button"
-                    tabindex="-1"
-                    >Apply to all</gr-button
-                  >
-                  <gr-button
-                    id="remove-hashtags-button"
-                    flatten=""
-                    aria-disabled="true"
-                    disabled=""
-                    role="button"
-                    tabindex="-1"
-                    >Remove</gr-button
-                  >
-                </div>
-              </div>
-            </div>
-          </iron-dropdown>
-        `,
-        {
-          // iron-dropdown sizing seems to vary between local & CI
-          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
-        }
-      );
-    });
-
-    test('remove single hashtag', async () => {
-      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
-      await element.updateComplete;
-      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Removing hashtag...'
-      );
-
-      await resolvePromises();
-      await element.updateComplete;
-
-      // not called for second change which as a different hashtag
-      assert.isTrue(setChangeHashtagStub.calledOnce);
-      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
-        changesWithHashtags[0]._number,
-        {remove: ['hashtag1']},
-      ]);
-    });
-
-    test('remove multiple hashtags', async () => {
-      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
-      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
-      await element.updateComplete;
-      queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Removing hashtags...'
-      );
-
-      await resolvePromises();
-      await element.updateComplete;
-
-      // not called for second change which as a different hashtag
-      assert.isTrue(setChangeHashtagStub.calledTwice);
-      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
-        changesWithHashtags[0]._number,
-        {remove: ['hashtag1', 'hashtag2']},
-      ]);
-      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
-        changesWithHashtags[1]._number,
-        {remove: ['hashtag1', 'hashtag2']},
-      ]);
-    });
-
-    test('can only apply a single hashtag', async () => {
-      assert.isTrue(
-        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
-      );
-
-      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
-      await element.updateComplete;
-
-      assert.isFalse(
-        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
-      );
-
-      queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
-      await element.updateComplete;
-
-      assert.isTrue(
-        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
-      );
-    });
-
-    test('applies hashtag to all changes', async () => {
-      queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
-      await element.updateComplete;
-
-      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Applying hashtag to all'
-      );
-
-      await resolvePromises();
-      await element.updateComplete;
-
-      assert.isTrue(setChangeHashtagStub.calledTwice);
-      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
-        changesWithHashtags[0]._number,
-        {add: ['hashtag1']},
-      ]);
-      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
-        changesWithHashtags[1]._number,
-        {add: ['hashtag1']},
-      ]);
-    });
-  });
-
-  suite('change have no existing hashtags', () => {
-    const changesWithNoHashtags: ChangeInfo[] = [
-      {
-        ...createChange(),
-        _number: 1 as NumericChangeId,
-        subject: 'Subject 1',
-      },
-      {
-        ...createChange(),
-        _number: 2 as NumericChangeId,
-        subject: 'Subject 2',
-      },
-    ];
-    let setChangeHashtagPromises: MockPromise<string>[];
-    let setChangeHashtagStub: sinon.SinonStub;
-
-    async function resolvePromises() {
-      setChangeHashtagPromises[0].resolve('foo');
-      setChangeHashtagPromises[1].resolve('foo');
-      await element.updateComplete;
-    }
-
-    setup(async () => {
-      stubRestApi('getDetailedChangesWithActions').resolves(
-        changesWithNoHashtags
-      );
-      setChangeHashtagPromises = [];
-      setChangeHashtagStub = stubRestApi('setChangeHashtag');
-      for (let i = 0; i < changesWithNoHashtags.length; i++) {
-        const promise = mockPromise<string>();
-        setChangeHashtagPromises.push(promise);
-        setChangeHashtagStub
-          .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
-          .returns(promise);
-      }
-
-      model = new BulkActionsModel(getAppContext().restApiService);
-      model.sync(changesWithNoHashtags);
-
-      element = (
-        await fixture(
-          wrapInProvider(
-            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
-            bulkActionsModelToken,
-            model
-          )
-        )
-      ).querySelector('gr-change-list-hashtag-flow')!;
-
-      // select changes
-      await selectChange(changesWithNoHashtags[0]);
-      await selectChange(changesWithNoHashtags[1]);
-      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
-      await element.updateComplete;
-
-      // open flow
-      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
-      await element.updateComplete;
-      await flush();
-    });
-
-    test('renders no-existing-hashtags flow', () => {
-      expect(element).shadowDom.to.equal(
-        /* HTML */ `
-          <gr-button
-            id="start-flow"
-            flatten=""
-            down-arrow=""
-            aria-disabled="false"
-            role="button"
-            tabindex="0"
-            >Hashtag</gr-button
-          >
-          <iron-dropdown
-            aria-disabled="false"
-            vertical-align="auto"
-            horizontal-align="auto"
-          >
-            <div slot="dropdown-content">
               <gr-autocomplete
                 placeholder="Type hashtag name to create or filter hashtags"
                 show-blue-focus-border=""
@@ -466,6 +262,79 @@
       );
     });
 
+    test('apply hashtag from selected change', async () => {
+      // selects "hashtag1"
+      queryAll<HTMLSpanElement>(element, 'span.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 resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1']},
+      ]);
+    });
+
+    test('apply existing hashtag not on selected changes', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      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, '#create-new-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying hashtag...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+    });
+
     test('create new hashtag', async () => {
       const getHashtagsStub = stubRestApi(
         'getChangesWithSimilarHashtag'
@@ -493,51 +362,17 @@
       await resolvePromises();
       await element.updateComplete;
 
-      assert.isTrue(setChangeHashtagStub.calledTwice);
+      assert.isTrue(setChangeHashtagStub.calledThrice);
       assert.deepEqual(setChangeHashtagStub.firstCall.args, [
-        changesWithNoHashtags[0]._number,
+        changes[0]._number,
         {add: ['foo']},
       ]);
       assert.deepEqual(setChangeHashtagStub.secondCall.args, [
-        changesWithNoHashtags[1]._number,
+        changes[1]._number,
         {add: ['foo']},
       ]);
-    });
-
-    test('apply hashtag', async () => {
-      const getHashtagsStub = stubRestApi(
-        'getChangesWithSimilarHashtag'
-      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
-      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, '#create-new-hashtag-button').disabled
-      );
-
-      queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
-      await element.updateComplete;
-
-      assert.equal(
-        queryAndAssert(element, '.loadingText').textContent,
-        'Applying hashtag...'
-      );
-
-      await resolvePromises();
-
-      assert.isTrue(setChangeHashtagStub.calledTwice);
-      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
-        changesWithNoHashtags[0]._number,
-        {add: ['foo']},
-      ]);
-      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
-        changesWithNoHashtags[1]._number,
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
         {add: ['foo']},
       ]);
     });