Merge "Remove more errFns that are not required"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 43c3b9e..1a5920f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1388,6 +1388,8 @@
 
 The destination branch must be provided in the request body inside a
 link:#move-input[MoveInput] entity.
+Only veto votes that are blocking the change from submission are moved to
+the destination branch by default.
 
 .Request
 ----
@@ -7354,6 +7356,11 @@
 |`destination_branch`||Destination branch
 |`message`           |optional|
 A message to be posted in this change's comments
+|`keep_all_labels`   |optional, defaults to false|
+By default, only veto votes that are blocking the change from submission are moved to
+the destination branch. Using this option is only allowed for administrators,
+because it can affect the submission behaviour of the change (depending on the label access
+configuration and submissions rules).
 |===========================
 
 [[notify-info]]
diff --git a/java/com/google/gerrit/extensions/api/changes/MoveInput.java b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
index 795642a..3d82990 100644
--- a/java/com/google/gerrit/extensions/api/changes/MoveInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/MoveInput.java
@@ -17,4 +17,14 @@
 public class MoveInput {
   public String message;
   public String destinationBranch;
+  /**
+   * Whether or not to keep all votes in the destination branch. Keeping the votes can be confusing
+   * in the context of the destination branch, see
+   * https://gerrit-review.googlesource.com/c/gerrit/+/129171. That is why only the users with
+   * {@link com.google.gerrit.server.permissions.GlobalPermission#ADMINISTRATE_SERVER} permissions
+   * can use this option.
+   *
+   * <p>By default, only the veto votes that are blocking the change from submission are moved.
+   */
+  public boolean keepAllVotes;
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 577174f..8ec394c 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -140,6 +141,18 @@
     // Not allowed to move if the current patch set is locked.
     psUtil.checkPatchSetNotLocked(rsrc.getNotes());
 
+    // Keeping all votes can be confusing in the context of the destination branch, see the
+    // discussion in
+    // https://gerrit-review.googlesource.com/c/gerrit/+/129171
+    // Only administrators are allowed to keep all labels at their own risk.
+    try {
+      if (input.keepAllVotes) {
+        permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+      }
+    } catch (AuthException denied) {
+      throw new AuthException("move is not permitted with keepAllVotes option", denied);
+    }
+
     // Move requires abandoning this change, and creating a new change.
     try {
       rsrc.permissions().check(ABANDON);
@@ -226,7 +239,9 @@
       update.setBranch(newDestKey.branch());
       change.setDest(newDestKey);
 
-      updateApprovals(ctx, update, psId, projectKey);
+      if (!input.keepAllVotes) {
+        updateApprovals(ctx, update, psId, projectKey);
+      }
 
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index d5881ea..19e36f2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -16,15 +16,19 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -190,7 +194,7 @@
         .update();
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -210,7 +214,7 @@
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("move not permitted");
+    assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
   }
 
   @Test
@@ -269,6 +273,224 @@
   }
 
   @Test
+  public void moveChangeKeepAllVotesOnlyAllowedForAdmins() throws Exception {
+    // Keep all votes options is only permitted for admins.
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    // Grant change permissions to the registered users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref(destinationBranch.branch()).group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.ABANDON).ref(sourceBranch.branch()).group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> move(changeId, destinationBranch.shortName(), true));
+    assertThat(thrown).hasMessageThat().isEqualTo("move is not permitted with keepAllVotes option");
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    move(changeId, destinationBranch.branch(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesNoLabelInDestination() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-1; 1]
+    configLabel(testLabelA, LabelFunction.NO_BLOCK, ImmutableList.of(sourceBranch.branch()));
+    // Registered users have permissions for the entire range [-1; 1] on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 1);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // Label is missing in the destination branch.
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes()).isEmpty();
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 1);
+  }
+
+  @Test
+  public void moveChangeKeepAllVotesOutOfUserPermissionRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    String testLabelA = "Label-A";
+    // The label has the range [-2; 2]
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+    // Registered users have [-2; 2] permissions on the source.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA).ref(sourceBranch.branch()).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    // Registered users have [-1; 1] permissions on the destination.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(testLabelA)
+                .ref(destinationBranch.branch())
+                .group(REGISTERED_USERS)
+                .range(-1, +1))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+    requestScopeOperations.setApiUser(user.id());
+    // Vote within the range of the source branch.
+    ReviewInput userReviewInput = new ReviewInput();
+    userReviewInput.label(testLabelA, 2);
+    gApi.changes().id(changeId).current().review(userReviewInput);
+
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
+
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.branch(), true);
+    // User does not have label permissions for the same vote on the destination branch.
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.changes().id(changeId).current().review(userReviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Applying label \"%s\": 2 is restricted", testLabelA));
+
+    // Label is kept even though the user's permission range is different from the source.
+    // Since we do not squash users votes based on the destination branch access label
+    // configuration, this is working as intended.
+    // It's the same behavior as when a project owner reduces user's permission range on label.
+    // Administrators should take this into account.
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertLabelVote(user, changeId, testLabelA, (short) 2);
+  }
+
+  @Test
+  public void moveKeepAllVotesCanMoveAllInRange() throws Exception {
+    BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
+    createBranch(destinationBranch);
+    BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
+    createBranch(sourceBranch);
+
+    // The non-block label has the range [-2; 2]
+    String testLabelA = "Label-A";
+    configLabel(
+        project,
+        testLabelA,
+        LabelFunction.NO_BLOCK,
+        value(2, "Passes"),
+        value(1, "Mostly ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"),
+        value(-2, "Failed"));
+
+    // Registered users have [-2; 2] permissions on all branches.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
+
+    for (int vote = -2; vote <= 2; vote++) {
+      TestAccount testUser = accountCreator.create("TestUser" + vote);
+      requestScopeOperations.setApiUser(testUser.id());
+      ReviewInput userReviewInput = new ReviewInput();
+      userReviewInput.label(testLabelA, vote);
+      gApi.changes().id(changeId).current().review(userReviewInput);
+    }
+
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    requestScopeOperations.setApiUser(admin.id());
+    // Move the change to the destination branch.
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    move(changeId, destinationBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
+
+    // All votes are kept
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+
+    // Move the change back to the source, the label is kept.
+    move(changeId, sourceBranch.shortName(), true);
+    assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
+    assertThat(
+            gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
+                .map(approvalInfo -> approvalInfo.value)
+                .collect(ImmutableList.toImmutableList()))
+        .containsExactly(-2, -1, 1, 2);
+  }
+
+  @Test
   public void moveChangeOnlyKeepVetoVotes() throws Exception {
     // A vote for a label will be kept after moving if the label's function is *WithBlock and the
     // vote holds the minimum value.
@@ -394,10 +616,28 @@
     gApi.changes().id(changeId).move(in);
   }
 
+  private void move(String changeId, String destination, boolean keepAllVotes)
+      throws RestApiException {
+    MoveInput in = new MoveInput();
+    in.destinationBranch = destination;
+    in.keepAllVotes = keepAllVotes;
+    gApi.changes().id(changeId).move(in);
+  }
+
   private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
     PushOneCommit.Result result = push.to("refs/for/" + branch);
     result.assertOkStatus();
     return result;
   }
+
+  private PushOneCommit.Result createChangeInBranch(String branch) throws Exception {
+    return createChange("refs/for/" + branch);
+  }
+
+  private void assertLabelVote(TestAccount user, String changeId, String label, short vote)
+      throws Exception {
+    assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes())
+        .containsEntry(label, vote);
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index 7c5364e..cbb3d95 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -42,6 +42,7 @@
   LabelNameToLabelTypeInfoMap,
 } from '../../../types/common';
 import {PolymerDomRepeatEvent} from '../../../types/types';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -140,9 +141,7 @@
       // For a new section, this is not fired because new permissions and
       // rules have to be added in order to save, modifying the ref is not
       // enough.
-      this.dispatchEvent(
-        new CustomEvent('access-modified', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
   }
@@ -275,18 +274,11 @@
       return;
     }
     if (this.section.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-section-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-section-removed');
     }
     this._deleted = true;
     this.section.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 4311039..f5170ed 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -34,7 +34,11 @@
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
@@ -209,6 +213,7 @@
             name: groupName,
             external: !this._groupIsInternal,
           };
+          fireEvent(this, 'name-changed');
           this.dispatchEvent(
             new CustomEvent('name-changed', {
               detail,
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index e7dc56e..65a3b00 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -55,6 +55,7 @@
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {PolymerDomRepeatEvent} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -222,9 +223,7 @@
     }
     this.permission.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleRemovePermission() {
@@ -232,18 +231,11 @@
       return;
     }
     if (this.permission.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-permission-removed', {
-          bubbles: true,
-          composed: true,
-        })
-      );
+      fireEvent(this, 'added-permission-removed');
     }
     this._deleted = true;
     this.permission.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   @observe('_rules.splices')
@@ -407,9 +399,7 @@
     value.added = true;
     // See comment above for why we cannot use "this.set(...)" here.
     this.permission.value.rules[groupId] = value;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _computeHasRange(name: string) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 55bd1a4..13d0e50 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -26,6 +26,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {property, customElement, observe} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -267,15 +268,11 @@
   _handleRemoveRule() {
     if (!this.rule) return;
     if (this.rule.value.added) {
-      this.dispatchEvent(
-        new CustomEvent('added-rule-removed', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'added-rule-removed');
     }
     this._deleted = true;
     this.rule.value.deleted = true;
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _handleUndoRemove() {
@@ -304,9 +301,7 @@
     }
     this.rule.value.modified = true;
     // Allows overall access page to know a change has been made.
-    this.dispatchEvent(
-      new CustomEvent('access-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'access-modified');
   }
 
   _setOriginalRuleValues(value: RuleValue) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 64342f4..558037d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -49,6 +49,7 @@
   Timestamp,
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {pluralize} from '../../../utils/string-util';
 
 enum ChangeSize {
   XS = 10,
@@ -155,8 +156,7 @@
     const titleParts: string[] = [];
     if (category === LabelCategory.UNRESOLVED_COMMENTS) {
       const num = change?.unresolved_comment_count ?? 0;
-      const plural = num > 1 ? 's' : '';
-      titleParts.push(`${num} unresolved comment${plural}`);
+      titleParts.push(pluralize(num, 'unresolved comment'));
     }
     const significantLabel =
       label.rejected || label.approved || label.disliked || label.recommended;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 287a34f..9afc5f4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -54,6 +54,7 @@
   isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -423,12 +424,7 @@
     }
 
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('next-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'next-page');
   }
 
   _prevPage(e: CustomKeyboardEvent) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 35f3aeb..f320296 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -23,6 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement} from '@polymer/decorators';
 import {htmlTemplate} from './gr-create-change-help_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,8 +44,6 @@
    */
   _handleCreateTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('create-tap', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'create-tap');
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index d2bdfd7..36ef429 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -81,6 +81,7 @@
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
+import {fireEvent} from '../../../utils/event-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -315,9 +316,7 @@
         this._settingTopic = false;
         this.set(['change', 'topic'], newTopic);
         if (newTopic !== lastTopic) {
-          this.dispatchEvent(
-            new CustomEvent('topic-changed', {bubbles: true, composed: true})
-          );
+          fireEvent(this, 'topic-changed');
         }
       });
   }
@@ -360,12 +359,7 @@
       .setChangeHashtag(this.change._number, {add: [newHashtag]})
       .then(newHashtag => {
         this.set(['change', 'hashtags'], newHashtag);
-        this.dispatchEvent(
-          new CustomEvent('hashtag-changed', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'hashtag-changed');
       });
   }
 
@@ -516,9 +510,7 @@
       .then(() => {
         target.disabled = false;
         this.set(['change', 'topic'], '');
-        this.dispatchEvent(
-          new CustomEvent('topic-changed', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'topic-changed');
       })
       .catch(() => {
         target.disabled = false;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 4c3a364..c683a32 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -53,7 +53,7 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {getComputedStyleValue} from '../../../utils/dom-util';
 import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -150,7 +150,7 @@
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
-import {fireAlert, firePageError} from '../../../utils/event-util';
+import {fireAlert, fireEvent, firePageError} from '../../../utils/event-util';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {fireTitleChange} from '../../../utils/event-util';
 
@@ -933,8 +933,7 @@
     const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
     if (commentCnt === 0) return `Patchset ${patch._number}`;
-    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
-    return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+    return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
   }
 
   _computeRobotCommentsPatchSetDropdownItems(
@@ -1023,14 +1022,9 @@
   ) {
     if (!changeComments) return undefined;
     const draftCount = changeComments.computeDraftCount();
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
-    const draftString = GrCountStringFormatter.computePluralString(
-      draftCount,
-      'draft'
-    );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
+    const draftString = pluralize(draftCount, 'draft');
 
     return (
       unresolvedString +
@@ -1622,12 +1616,7 @@
     }
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
-        this.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'show-auth-required');
         return;
       }
 
@@ -2175,12 +2164,7 @@
     const loadingFlagSet = detailCompletes
       .then(() => {
         this._loading = false;
-        this.dispatchEvent(
-          new CustomEvent('change-details-loaded', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(this, 'change-details-loaded');
       })
       .then(() => {
         this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index c4b701f..7225bb9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -622,9 +622,7 @@
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
           change-comments="[[_changeComments]]"
-          drafts="[[_diffDrafts]]"
           revisions="[[_change.revisions]]"
-          project-config="[[_projectConfig]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 6c8082f..6e2e595 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -27,6 +27,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo, ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {pluralize} from '../../../utils/string-util';
 
 export interface GrConfirmSubmitDialog {
   $: {
@@ -73,8 +74,8 @@
 
   _computeUnresolvedCommentsWarning(change: ChangeInfo) {
     const unresolvedCount = change.unresolved_comment_count;
-    const plural = unresolvedCount && unresolvedCount > 1 ? 's' : '';
-    return `Heads Up! ${unresolvedCount} unresolved comment${plural}.`;
+    if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
+    return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
   _handleConfirmTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 01e3c5d..e436325 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -54,7 +54,7 @@
             url: 'my.url',
             ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -67,7 +67,7 @@
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1 ' +
                 '&& git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'ssh://andybons@localhost:29418/test-project ' +
                 'refs/changes/05/5/1',
@@ -77,7 +77,7 @@
             url: 'my.url',
             ref: 'refs/changes/5/6/1',
             commands: {
-              'Checkout':
+              Checkout:
                 'git fetch ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && git checkout FETCH_HEAD',
@@ -90,7 +90,7 @@
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1 && ' +
                 'git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
+              Pull:
                 'git pull ' +
                 'http://andybons@localhost:8080/a/test-project ' +
                 'refs/changes/05/5/1',
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 89df252..3c99648 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -54,6 +54,7 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -190,21 +191,11 @@
   }
 
   _expandAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('expand-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'expand-diffs');
   }
 
   _collapseAllDiffs() {
-    this.dispatchEvent(
-      new CustomEvent('collapse-diffs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'collapse-diffs');
   }
 
   _computeExpandedClass(filesExpanded: FilesExpandedState) {
@@ -341,22 +332,12 @@
 
   _handlePrefsTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-diff-prefs', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-diff-prefs');
   }
 
   _handleIncludedInTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('open-included-in-dialog', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'open-included-in-dialog');
   }
 
   _handleDownloadTap(e: Event) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 2786600..f3ccd61 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -38,7 +38,7 @@
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -54,7 +54,6 @@
 } from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
-  ConfigInfo,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
@@ -72,7 +71,6 @@
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {UIDraft} from '../../../utils/comment-util';
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {PatchSetFile} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
@@ -206,15 +204,9 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Object})
-  drafts?: {[path: string]: UIDraft[]};
-
   @property({type: Array})
   revisions?: {[revisionId: string]: RevisionInfo};
 
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
   @property({type: Number, notify: true})
   selectedIndex = -1;
 
@@ -645,14 +637,9 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    const commentString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
+    const commentString = pluralize(commentThreadCount, 'comment');
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
     return (
       commentString +
@@ -687,7 +674,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    return pluralize(draftCount, 'draft');
   }
 
   /**
@@ -714,7 +701,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    return draftCount === 0 ? '' : `${draftCount}d`;
   }
 
   /**
@@ -741,7 +728,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+    return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
   private _reviewFile(path: string, reviewed?: boolean) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 810a84e..ba92227 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -43,6 +43,8 @@
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-util';
+import {fireEvent} from '../../../utils/event-util';
 
 const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
@@ -215,13 +217,11 @@
   }
 
   _computeCommentCountText(threadsLength?: number) {
-    if (threadsLength === 0) {
+    if (!threadsLength) {
       return undefined;
-    } else if (threadsLength === 1) {
-      return '1 comment';
-    } else {
-      return `${threadsLength} comments`;
     }
+
+    return pluralize(threadsLength, 'comment');
   }
 
   _onThreadListModified() {
@@ -230,12 +230,7 @@
     // or gerrit level events
 
     // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'comment-refresh');
   }
 
   _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 6190e81..fc6b5ba 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -42,6 +42,7 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-util';
 
 function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
   return {changes: [], non_visible_changes: 0};
@@ -450,8 +451,7 @@
   }
 
   _computeNonVisibleChangesNote(n: number) {
-    const noun = n === 1 ? 'change' : 'changes';
-    return `(+ ${n} non-visible ${noun})`;
+    return `(+ ${pluralize(n, 'non-visible change')})`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 6301920..182a296 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -106,7 +106,8 @@
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
 import {isUnresolved} from '../../../utils/comment-util';
-import {fireAlert, fireServerError} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {fireAlert, fireEvent, fireServerError} from '../../../utils/event-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -446,12 +447,7 @@
     if (this.restApiService.hasPendingDiffDrafts()) {
       this._savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
-        this.dispatchEvent(
-          new CustomEvent('comment-refresh', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'comment-refresh');
         this._savingComments = false;
       });
     }
@@ -828,13 +824,7 @@
 
   _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
-    if (total === 0) {
-      return '';
-    }
-    if (total === 1) {
-      return '1 Draft';
-    }
-    return `${total} Drafts`;
+    return pluralize(total, 'Draft');
   }
 
   _computeMessagePlaceholder(canBeStarted: boolean) {
@@ -893,9 +883,7 @@
   _onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
-    this.dispatchEvent(
-      new CustomEvent('iron-resize', {composed: true, bubbles: true})
-    );
+    fireEvent(this, 'iron-resize');
   }
 
   _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
@@ -1366,12 +1354,7 @@
   }
 
   _handleHeightChanged() {
-    this.dispatchEvent(
-      new CustomEvent('autogrow', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'autogrow');
   }
 
   _handleLabelsChanged() {
@@ -1482,12 +1465,7 @@
     // or gerrit level events
 
     // emit the event so change-view can also get updated with latest changes
-    this.dispatchEvent(
-      new CustomEvent('comment-refresh', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'comment-refresh');
   }
 
   reportAttentionSetChanges(
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index dc04e38..e236652 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -32,6 +32,7 @@
 } from '@polymer/polymer/interfaces';
 import {ChangeInfo} from '../../../types/common';
 import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -116,10 +117,7 @@
     unresolvedOnly: boolean
   ) {
     if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return (
-        `Show ${threads.length} resolved comment` +
-        (threads.length > 1 ? 's' : '')
-      );
+      return `Show ${pluralize(threads.length, 'resolved comment')}`;
     }
     return '';
   }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 6b7e0b6..0361b9a 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -26,6 +26,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -115,12 +116,7 @@
   }
 
   _handleShortcutsTap() {
-    this.dispatchEvent(
-      new CustomEvent('show-keyboard-shortcuts', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireEvent(this, 'show-keyboard-shortcuts');
   }
 
   _handleLocationChange() {
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
deleted file mode 100644
index ccba289..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {css} from 'lit-element';
-
-export const cssTemplate = css`
-  .key {
-    background-color: var(--chip-background-color);
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    display: inline-block;
-    font-weight: var(--font-weight-bold);
-    padding: var(--spacing-xxs) var(--spacing-m);
-    text-align: center;
-  }
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 091684f..796a167 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -14,11 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from 'lit-html';
-import {GrLitElement} from '../../lit/gr-lit-element';
-import {customElement, property} from 'lit-element';
-import {cssTemplate} from './gr-key-binding-display.css';
-import {sharedStyles} from '../../../styles/shared-styles';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-key-binding-display_html';
+import {customElement, property} from '@polymer/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,30 +28,21 @@
 }
 
 @customElement('gr-key-binding-display')
-export class GrKeyBindingDisplay extends GrLitElement {
-  static get styles() {
-    return [sharedStyles, cssTemplate];
-  }
-
-  render() {
-    const items = this.binding.map((binding, index) => [
-      index > 0 ? html` or ` : html``,
-      this._computeModifiers(binding).map(
-        modifier => html`<span class="key modifier">${modifier}</span> `
-      ),
-      html`<span class="key">${this._computeKey(binding)}</span>`,
-    ]);
-    return html`${items}`;
+export class GrKeyBindingDisplay extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
   }
 
   @property({type: Array})
   binding: string[][] = [];
 
-  _computeModifiers(binding: string[]) {
+  _computeModifiers(binding: string[][]) {
     return binding.slice(0, binding.length - 1);
   }
 
-  _computeKey(binding: string[]) {
+  _computeKey(binding: string[][]) {
     return binding[binding.length - 1];
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
new file mode 100644
index 0000000..251e30f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .key {
+      background-color: var(--chip-background-color);
+      color: var(--primary-text-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      display: inline-block;
+      font-weight: var(--font-weight-bold);
+      padding: var(--spacing-xxs) var(--spacing-m);
+      text-align: center;
+    }
+  </style>
+  <template is="dom-repeat" items="[[binding]]">
+    <template is="dom-if" if="[[index]]">
+      or
+    </template>
+    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
+      <span class="key modifier">[[modifier]]</span>
+    </template>
+    <span class="key">[[_computeKey(item)]]</span>
+  </template>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
similarity index 86%
rename from polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
rename to polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
index 875cde8..0c25e6e 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
@@ -17,12 +17,11 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-key-binding-display.js';
-import {GrKeyBindingDisplay} from './gr-key-binding-display.js';
 
 const basicFixture = fixtureFromElement('gr-key-binding-display');
 
 suite('gr-key-binding-display tests', () => {
-  let element: GrKeyBindingDisplay;
+  let element;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -46,10 +45,10 @@
 
     test('key with modifiers', () => {
       assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
-        'Shift',
-        'Meta',
-      ]);
+      assert.deepEqual(
+          element._computeModifiers(['Shift', 'Meta', 'x']),
+          ['Shift', 'Meta']);
     });
   });
 });
+
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index d45c8e6..43b0588 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -39,6 +39,7 @@
 import {isRobot} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 export interface GrApplyFixDialog {
   $: {
@@ -129,12 +130,7 @@
     );
     return Promise.all(promises).then(() => {
       // ensures gr-overlay repositions overlay in center
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     });
   }
 
@@ -142,12 +138,7 @@
     super.attached();
     this.refitOverlay = () => {
       // re-center the dialog as content changed
-      this.$.applyFixOverlay.dispatchEvent(
-        new CustomEvent('iron-resize', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this.$.applyFixOverlay, 'iron-resize');
     };
     this.addEventListener('diff-context-expanded', this.refitOverlay);
   }
@@ -242,12 +233,7 @@
     this._currentPreviews = [];
     this._isApplyFixLoading = false;
 
-    this.dispatchEvent(
-      new CustomEvent('close-fix-preview', {
-        bubbles: true,
-        composed: true,
-      })
-    );
+    fireEvent(this, 'close-fix-preview');
     this.$.applyFixOverlay.close();
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index e7617b7..1f84680 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -449,12 +449,6 @@
   /** @override */
   created() {
     super.created();
-    this.addEventListener('reload-drafts', changeNum =>
-      // TODO(TS): This is a wrong code, however keep it as is for now
-      // If changeNum param in ChangeComments is removed, this also must be
-      // removed
-      this.reloadDrafts((changeNum as unknown) as NumericChangeId)
-    );
   }
 
   getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index 912d59e..6e613d3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -48,7 +48,7 @@
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {getLineNumber} from '../gr-diff/gr-diff-utils';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -219,17 +219,13 @@
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
-    this.dispatchEvent(
-      new CustomEvent('render-start', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'render-start');
     this._cancelableRenderPromise = util.makeCancelable(
       this.$.processor.process(this.diff.content, isBinary).then(() => {
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
-        this.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'render-content');
       })
     );
     return (
@@ -336,16 +332,7 @@
       sectionEl.parentNode.removeChild(sectionEl);
     }
 
-    this.async(
-      () =>
-        this.dispatchEvent(
-          new CustomEvent('render-content', {
-            composed: true,
-            bubbles: true,
-          })
-        ),
-      1
-    );
+    this.async(() => fireEvent(this, 'render-content'), 1);
   }
 
   cancel() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 31bd6d6..601ea80 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -28,6 +28,7 @@
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 import {MovedChunkGoToLineDetail} from '../../../types/events';
+import {pluralize} from '../../../utils/string-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -563,17 +564,17 @@
     let requiresLoad = false;
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
       if (this.useNewContextControls) {
-        text = `+${numLines} common line`;
-        button.setAttribute('aria-label', `Show ${numLines} common lines`);
+        text = `+${pluralize(numLines, 'common line')}`;
+        button.setAttribute(
+          'aria-label',
+          `Show ${pluralize(numLines, 'common line')}`
+        );
       } else {
-        text = `Show ${numLines} common line`;
+        text = `Show ${pluralize(numLines, 'common line')}`;
         const icon = this._createElement('iron-icon', 'showContext');
         icon.setAttribute('icon', 'gr-icons:unfold-more');
         button.appendChild(icon);
       }
-      if (numLines > 1) {
-        text += 's';
-      }
       requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
       if (requiresLoad) {
         // Expanding content would require load of more data
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
index 43ed77f..52ac7cc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -36,7 +36,7 @@
 import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -220,12 +220,7 @@
       ) {
         // reset for next file
         this.lastDisplayedNavigateToNextFileToast = null;
-        this.dispatchEvent(
-          new CustomEvent('navigate-to-next-unreviewed-file', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'navigate-to-next-unreviewed-file');
       } else {
         this.lastDisplayedNavigateToNextFileToast = Date.now();
         fireAlert(this, 'Press n again to navigate to next unreviewed file');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 3c401d0..32c9964 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -72,6 +72,7 @@
   firePageError,
   fireAlert,
   fireServerError,
+  fireEvent,
 } from '../../../utils/event-util';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
@@ -913,9 +914,7 @@
   }
 
   _handleCommentSaveOrDiscard() {
-    this.dispatchEvent(
-      new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
-    );
+    fireEvent(this, 'diff-comments-modified');
   }
 
   _isSyntaxHighlightingEnabled(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 6656ff1..a6f4717 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -39,7 +39,7 @@
   KeyboardShortcutMixin,
   Shortcut,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
@@ -1353,14 +1353,9 @@
       patchNum,
       path,
     });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
     const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 7092361..04714bb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -66,7 +66,7 @@
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {MovedChunkGoToLineEvent} from '../../../types/events';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
@@ -438,20 +438,10 @@
   _redispatchHoverEvents(addedThreadEls: HTMLElement[]) {
     for (const threadEl of addedThreadEls) {
       threadEl.addEventListener('mouseenter', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseenter', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(threadEl, 'comment-thread-mouseenter');
       });
       threadEl.addEventListener('mouseleave', () => {
-        threadEl.dispatchEvent(
-          new CustomEvent('comment-thread-mouseleave', {
-            bubbles: true,
-            composed: true,
-          })
-        );
+        fireEvent(threadEl, 'comment-thread-mouseleave');
       });
     }
   }
@@ -604,12 +594,7 @@
 
   _isValidElForComment(el: Element) {
     if (!this.loggedIn) {
-      this.dispatchEvent(
-        new CustomEvent('show-auth-required', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'show-auth-required');
       return false;
     }
     if (!this.patchRange) {
@@ -843,9 +828,7 @@
 
   _renderDiffTable() {
     if (!this.prefs) {
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
     if (
@@ -855,9 +838,7 @@
       this._safetyBypass === null
     ) {
       this._showWarning = true;
-      this.dispatchEvent(
-        new CustomEvent('render', {bubbles: true, composed: true})
-      );
+      fireEvent(this, 'render');
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 2a9fe54..7bd68cd 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -22,7 +22,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-patch-range-select_html';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {pluralize} from '../../../utils/string-util';
 import {appContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
@@ -378,16 +378,11 @@
     const commentThreadCount = changeComments.computeCommentThreadCount({
       patchNum,
     });
-    const commentThreadString = GrCountStringFormatter.computePluralString(
-      commentThreadCount,
-      'comment'
-    );
+    const commentThreadString = pluralize(commentThreadCount, 'comment');
 
     const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
-    const unresolvedString = GrCountStringFormatter.computeString(
-      unresolvedCount,
-      'unresolved'
-    );
+    const unresolvedString =
+      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
     if (!commentThreadString.length && !unresolvedString.length) {
       return '';
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index ff6efe6..ee52ab6 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -22,6 +22,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-selection-action-box_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -122,11 +123,6 @@
     } // 0 = main button
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('create-comment-requested', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-comment-requested');
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
index b3a40c5..a9aba13 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
@@ -17,6 +17,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-repo-command_html';
 import {customElement, property} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,8 +34,6 @@
   }
 
   _handleClick() {
-    this.dispatchEvent(
-      new CustomEvent('command-tap', {composed: true, bubbles: true})
-    );
+    fireEvent(this, 'command-tap');
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index 0544a9a..5786576 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -27,6 +27,7 @@
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 @customElement('gr-account-info')
 export class GrAccountInfo extends GestureEventListeners(
@@ -151,12 +152,7 @@
         this._hasDisplayNameChange = false;
         this._hasStatusChange = false;
         this._saving = false;
-        this.dispatchEvent(
-          new CustomEvent('account-detail-update', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'account-detail-update');
       });
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index fbc4607..85e692d 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -26,6 +26,7 @@
 import {ServerInfo, AccountDetailInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireEvent} from '../../../utils/event-util';
 
 export interface GrRegistrationDialog {
   $: {
@@ -135,12 +136,7 @@
 
     return Promise.all(promises).then(() => {
       this._saving = false;
-      this.dispatchEvent(
-        new CustomEvent('account-detail-update', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'account-detail-update');
     });
   }
 
@@ -156,12 +152,7 @@
 
   close() {
     this._saving = true; // disable buttons indefinitely
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'close');
   }
 
   _computeSaveDisabled(name?: string, email?: string, saving?: boolean) {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 08bc0f7..a6c4201 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -29,6 +29,7 @@
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends GestureEventListeners(
@@ -234,9 +235,7 @@
         reason
       )
       .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('hide-alert', {bubbles: true, composed: true})
-        );
+        fireEvent(this, 'hide-alert');
       });
   }
 
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 b7c7b69..aff6d50 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
@@ -27,6 +27,7 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {fireEvent} from '../../../utils/event-util';
 
 export interface GrAutocompleteDropdown {
   $: {
@@ -204,12 +205,7 @@
   }
 
   _fireClose() {
-    this.dispatchEvent(
-      new CustomEvent('dropdown-closed', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'dropdown-closed');
   }
 
   getCursorTarget() {
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 06555a7..6151a1d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -30,6 +30,7 @@
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {PaperInputElementExt} from '../../../types/types';
 import {CustomKeyboardEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
@@ -404,12 +405,7 @@
     if (this._suggestions.length) {
       this.set('_suggestions', []);
     } else {
-      this.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'cancel');
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 1401208..b2dcb88 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -58,13 +58,11 @@
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
 
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
 const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
@@ -809,11 +807,7 @@
     if (numPending === 0) {
       return SAVED_MESSAGE;
     }
-    return [
-      SAVING_MESSAGE,
-      numPending,
-      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-    ].join(' ');
+    return `Saving ${pluralize(numPending, 'draft')}...`;
   }
 
   _showStartRequest() {
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
deleted file mode 100644
index bbbce16..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-export const GrCountStringFormatter = {
-  /**
-   * Returns a count plus string that is pluralized when necessary.
-   */
-  computePluralString(count: number, noun: string): string {
-    return this.computeString(count, noun) + (count > 1 ? 's' : '');
-  },
-
-  /**
-   * Returns a count plus string that is not pluralized.
-   */
-  computeString(count: number, noun: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count} ${noun}`;
-  },
-
-  /**
-   * Returns a count plus arbitrary text.
-   */
-  computeShortString(count: number, text: string): string {
-    if (count === 0) {
-      return '';
-    }
-    return `${count}${text}`;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
deleted file mode 100644
index 36637ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrCountStringFormatter} from './gr-count-string-formatter.js';
-
-suite('gr-count-string-formatter tests', () => {
-  test('computeString', () => {
-    const noun = 'unresolved';
-    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeString(1, noun),
-        '1 unresolved');
-    assert.equal(GrCountStringFormatter.computeString(2, noun),
-        '2 unresolved');
-  });
-
-  test('computeShortString', () => {
-    const noun = 'c';
-    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
-    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
-  });
-
-  test('computePluralString', () => {
-    const noun = 'comment';
-    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
-        '1 comment');
-    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
-        '2 comments');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 9c9363b..10e8916 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -24,7 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -188,11 +188,6 @@
   _handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
-    this.dispatchEvent(
-      new CustomEvent('editable-content-cancel', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'editable-content-cancel');
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index dbb8725..fa244f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -24,6 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-linked-chip_html';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -64,11 +65,6 @@
 
   _handleRemoveTap(e: Event) {
     e.preventDefault();
-    this.dispatchEvent(
-      new CustomEvent('remove', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'remove');
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index 3403e87..97b45ac 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -25,6 +25,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {property, customElement} from '@polymer/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -96,12 +97,7 @@
   }
 
   _createNewItem() {
-    this.dispatchEvent(
-      new CustomEvent('create-clicked', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+    fireEvent(this, 'create-clicked');
   }
 
   _computeNavLink(
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
index 026cb3a..cd701fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -23,6 +23,7 @@
 import {customElement, property} from '@polymer/decorators';
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
 import {findActiveElement} from '../../../utils/dom-util';
+import {fireEvent} from '../../../utils/event-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -98,12 +99,7 @@
     return new Promise((resolve, reject) => {
       super.open.apply(this);
       if (this._isMobile()) {
-        this.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true,
-            bubbles: true,
-          })
-        );
+        fireEvent(this, 'fullscreen-overlay-opened');
         this._fullScreenOpen = true;
       }
       this._awaitOpen(resolve, reject);
@@ -118,12 +114,7 @@
   _overlayClosed() {
     window.removeEventListener('popstate', this._boundHandleClose);
     if (this._fullScreenOpen) {
-      this.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
+      fireEvent(this, 'fullscreen-overlay-closed');
       this._fullScreenOpen = false;
     }
     if (this.returnFocusTo) {
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index c2baaa9..695ae24 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -15,8 +15,6 @@
  * limitations under the License.
  */
 
-import {css} from 'lit-element';
-
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
@@ -24,276 +22,189 @@
 
 const $_documentContainer = document.createElement('template');
 
-export const sharedStyles = css`
-  /* CSS reset */
-
-  html,
-  body,
-  button,
-  div,
-  span,
-  applet,
-  object,
-  iframe,
-  h1,
-  h2,
-  h3,
-  h4,
-  h5,
-  h6,
-  p,
-  blockquote,
-  pre,
-  a,
-  abbr,
-  acronym,
-  address,
-  big,
-  cite,
-  code,
-  del,
-  dfn,
-  em,
-  img,
-  ins,
-  kbd,
-  q,
-  s,
-  samp,
-  small,
-  strike,
-  strong,
-  sub,
-  sup,
-  tt,
-  var,
-  b,
-  u,
-  i,
-  center,
-  dl,
-  dt,
-  dd,
-  ol,
-  ul,
-  li,
-  fieldset,
-  form,
-  label,
-  legend,
-  table,
-  caption,
-  tbody,
-  tfoot,
-  thead,
-  tr,
-  th,
-  td,
-  article,
-  aside,
-  canvas,
-  details,
-  embed,
-  figure,
-  figcaption,
-  footer,
-  header,
-  hgroup,
-  main,
-  menu,
-  nav,
-  output,
-  ruby,
-  section,
-  summary,
-  time,
-  mark,
-  audio,
-  video {
-    border: 0;
-    box-sizing: border-box;
-    font-size: 100%;
-    font: inherit;
-    margin: 0;
-    padding: 0;
-    vertical-align: baseline;
-  }
-  *::after,
-  *::before {
-    box-sizing: border-box;
-  }
-  input {
-    background-color: var(--background-color-primary);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    box-sizing: border-box;
-    color: var(--primary-text-color);
-    margin: 0;
-    padding: var(--spacing-s);
-  }
-  iron-autogrow-textarea {
-    background-color: inherit;
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    padding: 0;
-    box-sizing: border-box;
-    /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
-        css rule, which prevents overriding the border color. Clear that. */
-    -webkit-appearance: none;
-
-    --iron-autogrow-textarea: {
-      box-sizing: border-box;
-      padding: var(--spacing-s);
-    }
-  }
-  a {
-    color: var(--link-color);
-  }
-  input,
-  textarea,
-  select,
-  button {
-    font: inherit;
-  }
-  ol,
-  ul {
-    list-style: none;
-  }
-  blockquote,
-  q {
-    quotes: none;
-  }
-  blockquote:before,
-  blockquote:after,
-  q:before,
-  q:after {
-    content: '';
-    content: none;
-  }
-  table {
-    border-collapse: collapse;
-    border-spacing: 0;
-  }
-
-  /* Fonts */
-
-  .font-normal {
-    font-size: var(--font-size-normal);
-    font-weight: var(--font-weight-normal);
-    line-height: var(--line-height-normal);
-  }
-  .font-small {
-    font-size: var(--font-size-small);
-    font-weight: var(--font-weight-normal);
-    line-height: var(--line-height-small);
-  }
-  .heading-1 {
-    font-family: var(--header-font-family);
-    font-size: var(--font-size-h1);
-    font-weight: var(--font-weight-h1);
-    line-height: var(--line-height-h1);
-  }
-  .heading-2 {
-    font-family: var(--header-font-family);
-    font-size: var(--font-size-h2);
-    font-weight: var(--font-weight-h2);
-    line-height: var(--line-height-h2);
-  }
-  .heading-3 {
-    font-family: var(--header-font-family);
-    font-size: var(--font-size-h3);
-    font-weight: var(--font-weight-h3);
-    line-height: var(--line-height-h3);
-  }
-  iron-icon {
-    color: var(--deemphasized-text-color);
-    --iron-icon-height: 20px;
-    --iron-icon-width: 20px;
-  }
-
-  /* Stopgap solution until we remove hidden$ attributes. */
-
-  :host([hidden]),
-  [hidden] {
-    display: none !important;
-  }
-  .separator {
-    border-left: 1px solid var(--border-color);
-    height: 20px;
-    margin: 0 8px;
-  }
-  .separator.transparent {
-    border-color: transparent;
-  }
-  paper-toggle-button {
-    --paper-toggle-button-checked-bar-color: var(--link-color);
-    --paper-toggle-button-checked-button-color: var(--link-color);
-  }
-  paper-tabs {
-    font-size: var(--font-size-h3);
-    font-weight: var(--font-weight-h3);
-    line-height: var(--line-height-h3);
-    --paper-font-common-base: {
-      font-family: var(--header-font-family);
-      -webkit-font-smoothing: initial;
-    }
-    --paper-tab-content-focused: {
-      /* paper-tabs uses 700 here, which can look awkward */
-      font-weight: var(--font-weight-h3);
-    }
-    --paper-tab-content-unselected: {
-      /* paper-tabs uses 0.8 here, but we want to control the color directly */
-      opacity: 1;
-      color: var(--deemphasized-text-color);
-    }
-  }
-  iron-autogrow-textarea {
-    /** This is needed for firefox */
-    --iron-autogrow-textarea_-_white-space: pre-wrap;
-  }
-  strong {
-    font-weight: var(--font-weight-bold);
-  }
-
-  .assistive-tech-only {
-    user-select: none;
-    clip: rect(1px, 1px, 1px, 1px);
-    height: 1px;
-    margin: 0;
-    overflow: hidden;
-    padding: 0;
-    position: absolute;
-    white-space: nowrap;
-    width: 1px;
-    z-index: -1000;
-  }
-
-  /** BEGIN: loading spiner */
-  .loadingSpin {
-    border: 2px solid var(--disabled-button-background-color);
-    border-top: 2px solid var(--primary-button-background-color);
-    border-radius: 50%;
-    width: 10px;
-    height: 10px;
-    animation: spin 2s linear infinite;
-    margin-right: var(--spacing-s);
-  }
-  @keyframes spin {
-    0% {
-      transform: rotate(0deg);
-    }
-    100% {
-      transform: rotate(360deg);
-    }
-  }
-  /** END: loading spiner */
-`;
-
 $_documentContainer.innerHTML = `<dom-module id="shared-styles">
   <template>
     <style>
-    ${sharedStyles.cssText}
+
+      /* CSS reset */
+
+      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
+      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
+      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
+      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
+      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
+      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
+      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
+        border: 0;
+        box-sizing: border-box;
+        font-size: 100%;
+        font: inherit;
+        margin: 0;
+        padding: 0;
+        vertical-align: baseline;
+      }
+      *::after,
+      *::before {
+        box-sizing: border-box;
+      }
+      input {
+        background-color: var(--background-color-primary);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-sizing: border-box;
+        color: var(--primary-text-color);
+        margin: 0;
+        padding: var(--spacing-s);
+      }
+      iron-autogrow-textarea {
+        background-color: inherit;
+        color: var(--primary-text-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: 0;
+        box-sizing: border-box;
+        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+           css rule, which prevents overriding the border color. Clear that. */
+        -webkit-appearance: none;
+
+        --iron-autogrow-textarea: {
+          box-sizing: border-box;
+          padding: var(--spacing-s);
+        };
+      }
+      a {
+        color: var(--link-color);
+      }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
+      ol, ul {
+        list-style: none;
+      }
+      blockquote, q {
+        quotes: none;
+      }
+      blockquote:before, blockquote:after,
+      q:before, q:after {
+        content: '';
+        content: none;
+      }
+      table {
+        border-collapse: collapse;
+        border-spacing: 0;
+      }
+
+      /* Fonts */
+
+      .font-normal {
+        font-size: var(--font-size-normal);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-normal);
+      }
+      .font-small {
+        font-size: var(--font-size-small);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-small);
+      }
+      .heading-1 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h1);
+        font-weight: var(--font-weight-h1);
+        line-height: var(--line-height-h1);
+      }
+      .heading-2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .heading-3 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      iron-icon {
+        color: var(--deemphasized-text-color);
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+
+      /* Stopgap solution until we remove hidden$ attributes. */
+
+      :host([hidden]),
+      [hidden] {
+        display: none !important;
+      }
+      .separator {
+        border-left: 1px solid var(--border-color);
+        height: 20px;
+        margin: 0 8px;
+      }
+      .separator.transparent {
+        border-color: transparent;
+      }
+      paper-toggle-button {
+        --paper-toggle-button-checked-bar-color: var(--link-color);
+        --paper-toggle-button-checked-button-color: var(--link-color);
+      }
+      paper-tabs {
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+        --paper-font-common-base: {
+          font-family: var(--header-font-family);
+          -webkit-font-smoothing: initial;
+        };
+        --paper-tab-content-focused: {
+          /* paper-tabs uses 700 here, which can look awkward */
+          font-weight: var(--font-weight-h3);
+        };
+        --paper-tab-content-unselected: {
+          /* paper-tabs uses 0.8 here, but we want to control the color directly */
+          opacity: 1;
+          color: var(--deemphasized-text-color);
+        };
+      }
+      iron-autogrow-textarea {
+        /** This is needed for firefox */
+        --iron-autogrow-textarea_-_white-space: pre-wrap;
+      }
+      strong {
+        font-weight: var(--font-weight-bold);
+      }
+
+      .assistive-tech-only {
+        user-select: none;
+        clip: rect(1px, 1px, 1px, 1px);
+        height: 1px;
+        margin: 0;
+        overflow: hidden;
+        padding: 0;
+        position: absolute;
+        white-space: nowrap;
+        width: 1px;
+        z-index: -1000;
+      }
+
+      /** BEGIN: loading spiner */
+      .loadingSpin {
+        border: 2px solid var(--disabled-button-background-color);
+        border-top: 2px solid var(--primary-button-background-color);
+        border-radius: 50%;
+        width: 10px;
+        height: 10px;
+        animation: spin 2s linear infinite;
+        margin-right: var(--spacing-s);
+      }
+      @keyframes spin {
+        0% { transform: rotate(0deg); }
+        100% { transform: rotate(360deg); }
+      }
+      /** END: loading spiner */
     </style>
   </template>
 </dom-module>`;
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 36341bd..3dd1b9e 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -25,6 +25,15 @@
   TITLE_CHANGE = 'title-change',
 }
 
+export function fireEvent(target: EventTarget, type: string) {
+  target.dispatchEvent(
+    new CustomEvent(type, {
+      composed: true,
+      bubbles: true,
+    })
+  );
+}
+
 export function fireAlert(target: EventTarget, message: string) {
   target.dispatchEvent(
     new CustomEvent(EventType.SHOW_ALERT, {
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
new file mode 100644
index 0000000..36050ca
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns a count plus string that is pluralized when necessary.
+ */
+export function pluralize(count: number, noun: string): string {
+  if (count === 0) return '';
+  return `${count} ${noun}` + (count > 1 ? 's' : '');
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
new file mode 100644
index 0000000..9297d90
--- /dev/null
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {pluralize} from './string-util.js';
+
+suite('formatter util tests', () => {
+  test('pluralize', () => {
+    const noun = 'comment';
+    assert.equal(pluralize(0, noun), '');
+    assert.equal(pluralize(1, noun), '1 comment');
+    assert.equal(pluralize(2, noun), '2 comments');
+  });
+});