Merge "Fix link color in formatted text"
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 31a7a36..a9c6526 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
@@ -31,7 +31,7 @@
 
   @state() private topicToAdd: TopicName = '' as TopicName;
 
-  @state() private topicsToRemove: Set<TopicName> = new Set();
+  @state() private selectedExistingTopics: Set<TopicName> = new Set();
 
   @state() private existingTopicSuggestions: TopicName[] = [];
 
@@ -140,8 +140,8 @@
             <div slot="dropdown-content">
               ${when(
                 this.selectedChanges.some(change => change.topic),
-                () => this.renderRemoveMode(),
-                () => this.renderAddMode()
+                () => this.renderExistingTopicsMode(),
+                () => this.renderNoExistingTopicsMode()
               )}
             </div>
           `
@@ -150,22 +150,29 @@
     `;
   }
 
-  private renderRemoveMode() {
+  private renderExistingTopicsMode() {
     const topics = this.selectedChanges
       .map(change => change.topic)
       .filter(notUndefined)
       .filter(unique);
     const removeDisabled =
-      this.topicsToRemove.size === 0 ||
+      this.selectedExistingTopics.size === 0 ||
       this.overallProgress === ProgressStatus.RUNNING;
     return html`
       <div class="chips">
-        ${topics.map(name => this.renderTopicRemoveChip(name))}
+        ${topics.map(name => this.renderExistingTopicChip(name))}
       </div>
       <div class="footer">
         <div class="loadingOrError">${this.renderLoadingOrError()}</div>
         <div class="buttons">
           <gr-button
+            id="apply-to-all-button"
+            flatten
+            ?disabled=${this.selectedExistingTopics.size !== 1}
+            @click=${this.applyTopicToAll}
+            >Apply to all</gr-button
+          >
+          <gr-button
             id="remove-topics-button"
             flatten
             ?disabled=${removeDisabled}
@@ -177,15 +184,15 @@
     `;
   }
 
-  private renderTopicRemoveChip(name: TopicName) {
+  private renderExistingTopicChip(name: TopicName) {
     const chipClasses = {
       chip: true,
-      selected: this.topicsToRemove.has(name),
+      selected: this.selectedExistingTopics.has(name),
     };
     return html`
       <span
         class=${classMap(chipClasses)}
-        @click=${() => this.toggleTopicToRemove(name)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
       >
         ${name}
       </span>
@@ -196,7 +203,7 @@
     if (this.overallProgress === ProgressStatus.RUNNING) {
       return html`
         <span class="loadingSpin"></span>
-        <span>${this.loadingText}</span>
+        <span class="loadingText">${this.loadingText}</span>
       `;
     } else if (this.errorText !== undefined) {
       return html`<div class="error">${this.errorText}</div>`;
@@ -204,7 +211,7 @@
     return nothing;
   }
 
-  private renderAddMode() {
+  private renderNoExistingTopicsMode() {
     const isCreateNewTopicDisabled =
       this.topicToAdd === '' ||
       this.existingTopicSuggestions.includes(this.topicToAdd) ||
@@ -257,7 +264,7 @@
       this.dropdown?.close();
     } else {
       this.topicToAdd = '' as TopicName;
-      this.topicsToRemove = new Set();
+      this.selectedExistingTopics = new Set();
       this.overallProgress = ProgressStatus.NOT_STARTED;
       this.errorText = undefined;
       this.isDropdownOpen = true;
@@ -282,16 +289,31 @@
 
   private removeTopics() {
     this.loadingText = `Removing ${pluralize(
-      this.topicsToRemove.size,
+      this.selectedExistingTopics.size,
       'topic'
     )}...`;
     this.trackPromises(
       this.selectedChanges
-        .filter(change => change.topic && this.topicsToRemove.has(change.topic))
+        .filter(
+          change =>
+            change.topic && this.selectedExistingTopics.has(change.topic)
+        )
         .map(change => this.restApiService.setChangeTopic(change._number, ''))
     );
   }
 
+  private applyTopicToAll() {
+    this.loadingText = 'Applying to all';
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(
+          change._number,
+          Array.from(this.selectedExistingTopics.values())[0]
+        )
+      )
+    );
+  }
+
   private addTopic(loadingText: string) {
     this.loadingText = loadingText;
     this.trackPromises(
@@ -315,11 +337,11 @@
     }
   }
 
-  private toggleTopicToRemove(name: TopicName) {
-    if (this.topicsToRemove.has(name)) {
-      this.topicsToRemove.delete(name);
+  private toggleExistingTopicSelected(name: TopicName) {
+    if (this.selectedExistingTopics.has(name)) {
+      this.selectedExistingTopics.delete(name);
     } else {
-      this.topicsToRemove.add(name);
+      this.selectedExistingTopics.add(name);
     }
     this.requestUpdate();
   }
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 4203b85..677aeb4 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
@@ -192,7 +192,7 @@
       await flush();
     });
 
-    test('renders remove flow', () => {
+    test('renders existing-topics flow', () => {
       expect(element).shadowDom.to.equal(
         /* HTML */ `
           <gr-button
@@ -217,6 +217,15 @@
                 <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-topics-button"
                     flatten=""
                     aria-disabled="true"
@@ -241,6 +250,13 @@
       queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing 1 topic...'
+      );
+
       await resolvePromises();
       await element.updateComplete;
 
@@ -257,6 +273,13 @@
       queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
       await element.updateComplete;
       queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing 2 topics...'
+      );
+
       await resolvePromises();
       await element.updateComplete;
 
@@ -271,6 +294,52 @@
         '',
       ]);
     });
+
+    test('can only apply a single topic', 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 topic 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 to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        'topic1',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        'topic1',
+      ]);
+    });
   });
 
   suite('change have no existing topics', () => {
@@ -334,7 +403,7 @@
       await flush();
     });
 
-    test('renders create/apply flow', () => {
+    test('renders no-existing-topics flow', () => {
       expect(element).shadowDom.to.equal(
         /* HTML */ `
           <gr-button
@@ -405,6 +474,13 @@
       );
 
       queryAndAssert<GrButton>(element, '#create-new-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Creating topic...'
+      );
+
       await resolvePromises();
       await element.updateComplete;
 
@@ -437,6 +513,13 @@
       );
 
       queryAndAssert<GrButton>(element, '#apply-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying topic...'
+      );
+
       await resolvePromises();
 
       assert.isTrue(setChangeTopicStub.calledTwice);
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 23d0860..ae6f001 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -404,7 +404,7 @@
       case 'code':
         return html`<code>${block.text}</code>`;
       case 'pre':
-        return html`<code>${block.text}</code>`;
+        return html`<pre><code>${block.text}</code></pre>`;
       case 'list':
         return html`
           <ul>