Merge "Remove reload-drafts event listener"
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/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 4c3a364..f00f4eb 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';
@@ -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 +
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.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 7401026..e436325 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -15,29 +15,46 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-download-dialog.js';
+import '../../../test/common-test-setup-karma';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createChange,
+  createCommit,
+  createRevision,
+  createRevisions,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+} from '../../../types/common';
+import {GrDownloadDialog} from './gr-download-dialog';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
 function getChangeObject() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
     revisions: {
       '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
+        ...createRevision(),
+        commit: createCommit(),
         fetch: {
           repo: {
+            url: 'my.url',
+            ref: 'refs/changes/5/6/1',
             commands: {
               repo: 'repo download test-project 5/1',
             },
           },
           ssh: {
+            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',
@@ -50,15 +67,17 @@
                 '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',
             },
           },
           http: {
+            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',
@@ -71,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',
@@ -85,41 +104,24 @@
 
 function getChangeObjectNoFetch() {
   return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-    revisions: {
-      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
-        fetch: {},
-      },
-    },
+    ...createChange(),
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
+    revisions: createRevisions(1),
   };
 }
 
 suite('gr-download-dialog', () => {
-  let element;
+  let element: GrDownloadDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
-    element.patchNum = '1';
-    element.config = {
-      schemes: {
-        'anonymous http': {},
-        'http': {},
-        'repo': {},
-        'ssh': {},
-      },
-      archives: ['tgz', 'tar', 'tbz2', 'txz'],
-    };
-
+    element.patchNum = 1 as PatchSetNum;
+    element.config = createServerInfo();
     flush();
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(
-        element.root.querySelectorAll('a'));
+    const anchors = Array.from(element.root?.querySelectorAll('a')!);
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
@@ -152,17 +154,28 @@
     });
 
     test('computed fields', () => {
-      assert.equal(element._computeArchiveDownloadLink(
-          {project: 'test/project', _number: 123}, 2, 'tgz'),
-      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+      assert.equal(
+        element._computeArchiveDownloadLink(
+          {
+            ...createChange(),
+            project: 'test/project' as RepoName,
+            _number: 123 as NumericChangeId,
+          },
+          2 as PatchSetNum,
+          'tgz'
+        ),
+        '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
+      );
     });
 
     test('close event', done => {
       element.addEventListener('close', () => {
         done();
       });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.closeButtonContainer gr-button'));
+      const closeButton = element.shadowRoot!.querySelector(
+        '.closeButtonContainer gr-button'
+      );
+      tap(closeButton!);
     });
   });
 
@@ -172,35 +185,49 @@
   });
 
   test('_computeHidePatchFile', () => {
-    const patchNum = '1';
+    const patchNum = 1 as PatchSetNum;
 
     const changeWithNoParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: []}},
+        r1: {...createRevision(), commit: createCommit()},
       },
     };
     assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
 
     const changeWithOneParent = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+          },
+        },
       },
     };
     assert.isFalse(
-        element._computeHidePatchFile(changeWithOneParent, patchNum));
+      element._computeHidePatchFile(changeWithOneParent, patchNum)
+    );
 
     const changeWithMultipleParents = {
+      ...createChange(),
       revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-        ]}},
+        r1: {
+          ...createRevision(),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: 'subject1'},
+              {commit: 'p2' as CommitId, subject: 'subject2'},
+            ],
+          },
+        },
       },
     };
     assert.isTrue(
-        element._computeHidePatchFile(changeWithMultipleParents, patchNum));
+      element._computeHidePatchFile(changeWithMultipleParents, patchNum)
+    );
   });
 });
-
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..d5bda17 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';
@@ -645,14 +645,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 +682,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    return pluralize(draftCount, 'draft');
   }
 
   /**
@@ -714,7 +709,7 @@
         patchNum: patchRange.patchNum,
         path,
       });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    return draftCount === 0 ? '' : `${draftCount}d`;
   }
 
   /**
@@ -741,7 +736,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..9e57b63 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,7 @@
 import {CommentThread} from '../../../utils/comment-util';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {appContext} from '../../../services/app-context';
+import {pluralize} from '../../../utils/string-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 +216,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() {
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..593d1db 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
@@ -107,6 +107,7 @@
 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';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -828,13 +829,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) {
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/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-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-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/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/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');
+  });
+});